Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor b1bd8d8b20 share legacy XLS test helpers 2026-11-12 06:29:50 +00:00
Dylan M. Taylor 794b65ecac fix XLS validation warnings 2026-11-12 06:13:59 +00:00
Dylan M. Taylor 73a18c4f4c Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/reviewfix-3844 2026-11-12 05:41:20 +00:00
Dylan M. Taylor 0b4b36b865 Merge remote-tracking branch 'origin/switch-craft-libs-offcrypto' into codex/reviewfix-3843 2026-11-12 05:41:13 +00:00
Dylan M. Taylor 4251dad939 Merge remote-tracking branch 'origin/switch-craft-libs-csv' into codex/reviewfix-3842 2026-11-12 05:41:06 +00:00
Dylan M. Taylor 1b7e4f00dc Merge remote-tracking branch 'origin/switch-craft-libs-xls' into codex/reviewfix-3841 2026-11-12 05:40:59 +00:00
Dylan M. Taylor 4deedbb549 warn about dropped XLS rules 2026-11-12 05:40:53 +00:00
Dylan M. Taylor 8d4fb0b12e Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/propagate-3844 2026-11-12 05:37:34 +00:00
Dylan M. Taylor 203872b4db Merge remote-tracking branch 'origin/switch-craft-libs-offcrypto' into codex/propagate-3843 2026-11-12 05:37:27 +00:00
Dylan M. Taylor d3954beacf Merge remote-tracking branch 'origin/switch-craft-libs-csv' into codex/propagate-3842 2026-11-12 05:37:21 +00:00
Dylan M. Taylor 26e7112e0f Merge remote-tracking branch 'origin/switch-craft-libs-xls' into codex/propagate-3840 2026-11-12 05:37:12 +00:00
Dylan M. Taylor 3d31729de3 test(xls): expect malformed encryption error 2026-11-12 05:36:34 +00:00
Dylan M. Taylor 3ba4945e72 GridCraft: use shared XLS validation operator type 2026-11-12 05:24:28 +00:00
Dylan M. Taylor 0752f3181b Merge current OPC switch into XLS validation switch 2026-11-12 05:20:25 +00:00
Dylan M. Taylor b24ec5df6d Merge current encrypted workbook switch into OPC switch 2026-11-12 05:19:49 +00:00
Dylan M. Taylor d0c567992e Merge current CSV switch into encrypted workbook switch 2026-11-12 05:18:19 +00:00
Dylan M. Taylor 1c84e32f58 GridCraft: update XLS validation switch to current shared crates 2026-11-12 05:16:38 +00:00
Dylan M. Taylor cf3b11c858 Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/gridcraft-3844 2026-11-12 05:16:08 +00:00
Dylan M. Taylor 1e6c8b8b72 Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/gridcraft-3843 2026-11-12 05:15:57 +00:00
Dylan M. Taylor 3e0f073e9a GridCraft: update OPC switch to current shared crates 2026-11-12 05:15:56 +00:00
Dylan M. Taylor eec5db66f0 Merge remote-tracking branch 'origin/switch-craft-libs-offcrypto' into codex/gridcraft-3843 2026-11-12 05:15:18 +00:00
Dylan M. Taylor a440cfac28 Merge remote-tracking branch 'origin/switch-craft-libs-offcrypto' into codex/gridcraft-3842 2026-11-12 05:14:54 +00:00
Dylan M. Taylor 92a79db048 GridCraft: update encrypted XLS switch to current shared crates 2026-11-12 05:14:47 +00:00
Dylan M. Taylor 652c08a89b Merge remote-tracking branch 'origin/switch-craft-libs-csv' into codex/gridcraft-3842 2026-11-12 05:14:07 +00:00
Dylan M. Taylor aeb3fb19ce Merge latest CSV branch updates 2026-11-12 05:13:54 +00:00
Dylan M. Taylor e4ec0d7ed1 GridCraft: update CSV switch to current shared crates 2026-11-12 05:13:10 +00:00
Dylan M. Taylor 23487b0cb5 Merge current XLS switch into CSV switch 2026-11-12 05:12:51 +00:00
Dylan M. Taylor 0407b63663 GridCraft: update XLS import to current shared crate 2026-11-12 05:11:53 +00:00
Dylan M. Taylor b45698d109 tests: give the xls fixture its compound-file byte order mark
craft-cfb refuses a header without the 0xFFFE mark at offset 28
([MS-CFB] 2.2), so craft-xls's sniff called the fixture LegacyBinary.
The old hand-written parser never read that field, which is why the test
only broke with the switch. The fixture is a real workbook otherwise:
sniff now says Xls and read_xls parses it.
2026-11-12 05:08:22 +00:00
Dylan M. Taylor c913bf6615 tests: give the xls fixture its compound-file byte order mark
craft-cfb refuses a header without the 0xFFFE mark at offset 28
([MS-CFB] 2.2), so craft-xls's sniff called the fixture LegacyBinary.
The old hand-written parser never read that field, which is why the test
only broke with the switch. The fixture is a real workbook otherwise:
sniff now says Xls and read_xls parses it.
2026-11-12 05:08:20 +00:00
Dylan M. Taylor 1bd4ca8946 Test xls import with a valid compound file header 2026-11-12 05:05:01 +00:00
Dylan M. Taylor 0431741359 tests: give the xls fixture its compound-file byte order mark
craft-cfb refuses a header without the 0xFFFE mark at offset 28
([MS-CFB] 2.2), so craft-xls's sniff called the fixture LegacyBinary.
The old hand-written parser never read that field, which is why the test
only broke with the switch. The fixture is a real workbook otherwise:
sniff now says Xls and read_xls parses it.
2026-11-12 04:37:07 +00:00
Dylan M. Taylorandkiwi4boy ae7fbd81b7 xlsx: .xls data validation from craft-xls
craft-xls reads Dv/DVal records (storytold/craft-libs#999, a port of #3636).
xls.rs maps each rule onto the model as the .xlsx reader maps a dataValidation:
a non-literal list source becomes =ref. The tests are #3636's, run through
read_xls; the cap test now expects craft-xls's skipped-rules warning.

Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
2026-11-12 02:47:52 +00:00
Dylan M. Taylor f800472b0a xlsx: OPC package access through craft-opc; craft-libs at aa84415b
package.rs keeps GridCraft's Package, Rel and ZipOut, now on craft_opc::LazyPackage
and craft_opc::ZipOut (on craft-zip). Limits stay GridCraft's (200,000 entries,
512 MB a part, 1 GB a package) and so do the budget rules, which craft-opc took
from GridCraft. The budget tests moved with them. normalize, resolve and
split_dir are craft-opc's (the same code).

Every craft-libs crate moves to aa84415b. craft-xls now reads data validation;
until GridCraft maps it, a sheet with rules still warns that .xls data validation
is not imported. ods.rs matches entity names through a slice, since the new
dependencies make the old as_ref() call ambiguous.
2026-11-12 02:37:30 +00:00
Dylan M. Taylor 9c87c22662 xlsx: encrypted workbooks and compound files through craft-offcrypto and craft-cfb
Decrypting password-protected workbooks ([MS-OFFCRYPTO], Agile and Standard) moved
to storytold/craft-libs as craft-offcrypto (#931), on craft-cfb. GridCraft keeps
its DecryptError and the engine's messages; crypto.rs maps the crate's errors onto
it. The private compound-file reader and its tests are gone: sniffing looks for
the encryption streams with craft_cfb::has_entry_named (with the same fallback for
a damaged directory), and the test builders encrypt with craft-offcrypto and write
compound files with craft-cfb.

One difference, as craft-offcrypto has it: the dataIntegrity HMAC key is used at
the length it decrypts to (up to the hash size); GridCraft accepted only a
salt-size or hash-size key. Both verify what Office writes.
2026-11-12 01:34:24 +00:00
Dylan M. Taylor c1adf9b239 xlsx, engine: CSV decoding, parsing and quoting, and SYLK, through craft-csv
Text decoding (byte order marks, UTF-8, Windows-1256/1252), delimiter sniffing,
the record parser and field quoting moved to storytold/craft-libs as craft-csv
(#889), with the SYLK reader as craft_csv::sylk. GridCraft keeps typed values,
date-order inference, the used-range limit and the sheet writer in csv.rs, and in
io/sylk.rs the workbook a SYLK file becomes: formulas parsed (an unparsable one
is skipped and counted, as before), error values, number-format styles and column
widths.
2026-11-12 01:17:21 +00:00
Dylan M. Taylor 21df49c636 xlsx: read .xls workbooks through craft-xls
The BIFF8 reader moved to storytold/craft-libs as craft-xls (#878). GridCraft
keeps the mapping onto its model in xls.rs: formulas parsed, Excel's implicit @
for legacy formulas, pixel widths and heights, styles interned per XF, sheet-name
checks, print areas and titles, calculation settings and GridCraft's warning text.

The workbook now opens on the sheet it was saved on when that sheet is visible,
else on the first visible sheet (from #3551 by @Morzilla, whose test is kept).
RString cells are read as text.

Every craft-libs crate moves to one revision, 119415f4.
2026-11-12 00:58:43 +00:00
FredApps 7e0d96d74b CI: nightly release dry run (#3820)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:04:17 +01:00
Dylan M. Taylor 572bec289b engine: work limits for Advanced Filter, Remove Duplicates, filters, AutoFit, macro footprints; range sizes saturate (#3295)
* engine: bound Advanced Filter, Remove Duplicates, AutoFilter, Circle Invalid Data; one-pass AutoFit; saturating range dimensions

* address review: AutoFit scans each run of adjacent selected columns separately

* address review: report a truncated circle-invalid scan, no spill scan for plain cells in Replace, one pass for many AutoFit runs

* address review: warn in the app when Circle Invalid Data stopped early; Replace test requires success

* address review: the scan note joins an open-time notice; say cell visits
2026-11-11 16:10:10 -05:00
Dylan M. Taylor 55eb394044 engine: index merges and bound the page in HTML export and clipboard HTML (#3266)
* engine: index merges and bound the page in HTML export and clipboard HTML

Save as Web Page and the clipboard's HTML scanned every merge of the
sheet for every cell, counted a merge's span over all the rows the file
gave it, placed each picture by summing every custom row before it, and
grew the page without a byte limit. Merges are now indexed once over
the exported rectangle (overlapping merges past a few visits per cell
are refused), spans use prefix counts clipped to the table, anchors use
LineIndex, and the page stops at 256 MiB with an error.

* address review: check the web page size after each chart and before returning it

* address review: bound the merge index, escape cell text within the page's room, size images before encoding

* address review: escape image attributes within the page's room
2026-11-11 16:09:43 -05:00
Dylan M. Taylor 9e9f13114f check, xtask stats: visited directories keyed by (dev, inode); stats reads only regular files (#3287)
* Directory walkers key visited directories by (dev, inode); stats skips non-regular or oversized .rs targets

* address review: record a walked directory only within the depth limit; stats skips symlinked sources and bounds the read

* address review: rescan a directory reached from a shallower path, skipping files already collected

* xtask stats: test the directory walk (links, identity, size limit)

* address review: create the test directories atomically and privately

* xtask stats tests: no unreachable! in the directory helper

* gridcraft check: unit-test that each workbook is listed once whatever path reaches it
2026-11-11 16:09:17 -05:00
Dylan M. Taylor 35e9815603 Security: one work budget across every call of a lifted function (#3197)
* functions: one work budget across every call of a lifted function

A function lifted over a computed array runs once per element, and only the
six lookups counted their repeated work against LIFTED_SCAN_BUDGET. Criteria
aggregates rescanned their ranges per criterion, CUMIPMT/CUMPRINC stepped
through up to 1e7 periods per element, and array-producing functions such as
MUNIT built a whole result per element before keeping its first cell.

The budget now covers all of it: COUNTIF/SUMIF/AVERAGEIF(S)/MAXIFS/MINIFS
charge their range cells per call, the period loops of every element are
added up before any runs, and array results charge their cells as they are
built. Past the budget the call is #NUM!, as lifted lookups already are.

* address review: charge numeric-text periods of lifted CUMIPMT/CUMPRINC to the work budget

* address review: charge only calls that reach the cumulative period loop

* address review: charge the numeric text a lifted period call parses

* address review: SUMIF/AVERAGEIF charge only the sum-range cells they read; weight cumulative period work
2026-11-11 16:08:50 -05:00
Dylan M. Taylor 6ea8d11522 print: bound the print area to the grid; render charts once and budget picture and chart output (#3314)
* print: hold the print area to the grid; render each chart once and budget picture and chart output across pages

* address review: budget chart path points and drop charts that do not fit the budget
2026-11-11 16:08:24 -05:00
Dylan M. Taylor 7c3af8a421 fix(formula): bound the wrappers stacked on one path of a parsed formula (#3813) 2026-11-11 16:07:58 -05:00
Dylan M. Taylor ca7ea0131e fix(mcp): send the control token only to a loopback address (#3814) 2026-11-11 16:07:32 -05:00
Dylan M. Taylor aa0347cc2e fix(engine): calculated pivot fields may not build arrays from scalars (#3812) 2026-11-11 16:07:06 -05:00
Dylan M. Taylor 5cf52c5fbb chore: bump version to 0.19.1 for the Flatpak update fix (#3809) 2026-11-11 14:55:53 -05:00
Dylan M. Taylor 0c5b1e85e6 sonar: stop counting the locale table as duplicated code (#3805) 2026-11-11 14:33:16 -05:00
Dylan M. Taylor 7f6a79fd98 Fix Flatpak permissions for update checks and self-updates (#3803)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:13 -05:00
Dylan M. Taylor 6dbfbc4058 Merge pull request #3795 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:44:31 -05:00
Dylan M. Taylor e258db5f19 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:44:25 -05:00
Dylan M. Taylor 763816d27c ci: run Harden-Runner on every job (#3786)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:11:59 -05:00
FredApps 90180f4d87 Sandbox: build on riscv64, where libc names no kexec_file_load (#3777)
The seccomp list named libc::SYS_kexec_file_load, which libc (0.2.190) defines for x86_64 and
aarch64 but not riscv64, so the release's Linux riscv64 build stopped with E0425. riscv64 takes
the number from the kernel's generic table, 294, as aarch64 does; the test checks libc's aarch64
value against it.
2026-11-11 17:35:44 +01:00
FredApps d69a285efb Release: GridCraft v0.19.0 (#3770) 2026-11-11 16:38:52 +01:00
Dylan M. Taylor 94fc52efae xlsx: share inline dxf number formats within a styles part (#3260)
* xlsx: share each custom number format across cell formats, cap font names at 31 characters

Every cell format record copied its font (name included) and built its
own copy of its number-format code, so many short records naming one
long font or code multiplied it in memory. Custom codes are now built
once and shared, and font names are cut to Excel's 31 characters.

* xlsx: drop stray worktree entries added by mistake

* address review: compare truncated theme font for the scheme link; intern inline dxf number formats
2026-11-11 08:50:20 -05:00
Dylan M. Taylor 7b825d00be Save as Web Page: a byte budget for the charts' SVG (an error past it) (#3209)
* Charts: one point budget per chart and a byte budget for web-page SVG

A chart resolved every series reference into its own vector of up to
100,000 points, so 255 short whole-column series retained and drew
25.5 million points, and Save as Web Page wrote one SVG element per
mark without any output limit.

- resolve() shares MAX_CHART_POINTS (1,000,000) across the values,
  X values and bubble sizes of all series; a series past the budget
  gets what is left.
- chart_svg() stops writing once a chart passes the bytes left of a
  64 MiB per-page budget and leaves that chart out.

* address review: charge X values to the chart point budget only when they are kept

* address review: charge scatter Y and X values to the chart point budget as a pair
2026-11-11 08:39:25 -05:00
Dylan M. Taylor 7726e9c6ef xls: charge expanded shared-formula text to the import's text allowance (#3256)
* xls: charge expanded shared-formula text to the import's text allowance

Shared-formula followers each kept their own printed formula text, but
none of it counted against the 256 MB text allowance, so compact PtgExp
records could expand into gigabytes of retained formulas. Each attach
now charges its text; past the allowance the formula is dropped and the
cell keeps its cached value.

* address review: keep resolving array formulas after the shared-formula text allowance is spent

* address review: keep the shared-formula text cut-off across worksheets

* address review: expand shared formulas only after every sheet's mandatory text is charged

* address review: refuse duplicate sheet names at import; adapt tests to read_xls_budgeted
2026-11-11 08:28:28 -05:00
Dylan M. Taylor 9a025205bd pivot: Rank and Running Total computed once per sibling set; accumulator, caption and chart-series bounds (#3306)
* pivot: cached Rank and Running Total, accumulator-width limit, capped subtotal captions and PivotChart series

* address review: store running-total entries only for items with a number

* address review: running totals visit only the top-level items that hold the path
2026-11-11 08:16:57 -05:00
Dylan M. Taylor 01abd95420 xlsx: resolve cell pictures through a per-structure layout and a relationship index (#3255)
* xlsx: resolve cell pictures through a per-structure layout and a relationship index

Every rich value rebuilt its structure's whole key list before checking
its value count, and scanned the image relationship table for a
duplicate id. Structures are now laid out once and relationships are
indexed by id (a repeated id stays invalid), so reading cell-picture
metadata is linear in its records.

* address review: wide-structure fixture uses the right count and loads a valid picture
2026-11-11 08:02:00 -05:00
Dylan M. Taylor e5ac9474df engine: Scenario Summary size limit, bounded solver report names, undo history counts comment and link text (#3308)
* engine: bound Scenario Summary size and solver report name searches; charge comment and link text to undo history

* address review: count result rows in the scenario summary limit and reply/date vector storage in the history estimate
2026-11-11 07:40:55 -05:00
Dylan M. Taylor b21a8541e7 Row auto-fit: sweep merges once per sheet instead of per row (#3195)
* Row auto-fit: sweep merges once per sheet instead of per row

auto_row_height() picked the merges touching a row by scanning every
merge on the sheet, and opening a workbook (fit_imported_rows) or a
commit fitting many rows called it per row, so rows x merges
comparisons ran synchronously on open.

Rows are fitted in ascending order, so a MergeSweep sorts the sheet's
merges by first row once and keeps only those active on the current
row. All comparisons count against MERGE_FIT_WORK (50,000,000) per
pass; past it the remaining rows keep the heights they have.
auto_row_height keeps its signature for single rows.

* address review: share the merge-comparison budget across the whole auto-fit pass

* address review: build the merge sweep lazily, after the row fast paths

* address review: charge the merge sort to the fit budget, keep row heights once it is spent

* address review: a pass that fits one row scans the merges instead of sorting them
2026-11-11 07:26:18 -05:00
Dylan M. Taylor a6c331b63f calc: one cumulative work budget per formula, and INDEX decides multi-cell picks from their shape (#3188)
* calc: INDEX decides a multi-cell position from its shape before reading it

* calc: charge each formula's evaluation against one cumulative work budget

* address review: a database function whose computed criterion ran out of work is #CALC!

* address review: give the test's criteria cell its stored value

* address review: stop matching database records once a computed criterion exhausts the work budget

* address review: skip the criteria-row scan once work is exhausted

* address review: stop between conditions once the work budget is spent
2026-11-11 07:17:04 -05:00
Dylan M. Taylor 12d26d53cc xlsx: analyse each kept chart sheet's graph once per save (#3261)
* xlsx: analyse each kept chart sheet's graph once per save

chart_sheet_usable walked a chart sheet's relationship graph and parsed
its charts for every workbook entry, so many entries naming one shared
graph multiplied the work. The referenced sheet names are now cached by
relationship id.

* address review: cache the chart-sheet usability verdict per relationship id instead of every reference

* address review: cache chart-sheet analysis by the graph root, not the relationship id
2026-11-11 07:06:18 -05:00
Dylan M. Taylor 9dc7e3826a numfmt: TEXT returns #VALUE! when its result passes a cell's 32,767 characters (#3264)
* numfmt: TEXT returns #VALUE! when its result passes a cell's 32,767 characters

A text section repeats its input once per @ placeholder, and TEXT handed
back whatever that built, so nested TEXT calls could multiply a long
text until memory ran out. Results past the cell text limit are now
#VALUE!.

* address review: stop TEXT rendering past the cell limit and count UTF-16 units
2026-11-11 06:44:34 -05:00
Dylan M. Taylor f79a425ca9 xlsx: find a reused picture through a content hash instead of comparing every media part (#3214)
* xlsx: find a reused picture through a content hash instead of comparing every media part

Out::image_part compared each new picture byte by byte with every
media part written so far, so a workbook of many distinct pictures
sharing a long prefix was exported in quadratic time. Media parts are
now indexed by extension and SHA-256.

* address review: index preserved media parts when they are added

* address review: do not hash an image twice when it is first added
2026-11-11 06:32:44 -05:00
Dylan M. Taylor 76e53085e3 xlsx: keep the cached value of a follower whose shared-formula copy is refused (#3212)
* xlsx: one hyperlink allowance per sheet, and a copy limit for unparsable shared masters

Every <hyperlinks> element of a worksheet started a fresh cell and byte
allowance although all of them fill the same map. The sheet now keeps
one allowance across its elements.

Followers of a shared master the parser refuses each copied its full
text; a sheet full of followers of a long master multiplied that text
without bound. The copies are now charged against a 64 MiB per-sheet
limit, past which the followers keep their cached values.

* address review: keep the cached value of a follower dropped for the shared-formula copy limit inside array ranges
2026-11-11 06:15:08 -05:00
Dylan M. Taylor bc2dabc7ea FORMULATEXT and the in-cell editor keep Hidden formulas hidden (#3298)
* FORMULATEXT and the cell editor keep a hidden formula's source hidden

* address review: recalculate formulas when sheet protection changes

* address review: skip the full recalculation for a no-op unprotect

* address review: recalculate on protect/unprotect without marking the edit structural
2026-11-11 05:48:31 -05:00
Dylan M. Taylor e14f783503 Analysis ToolPak: stop building a report once it outgrows the output limit (#3207)
* Analysis ToolPak: stop building a report once it outgrows the output limit

Report builders filled their whole output map before write_report
checked its size, so Descriptive Statistics grouped by rows over a
tall, nearly empty column allocated about 16 entries for each of up
to a million variables before refusing the report.

Grid now tracks its extent as values are stored and keeps nothing
once the report's rows x columns pass MAX_OUTPUT_CELLS; write_report
refuses such a report. extent() no longer rescans every entry.

* address review: stop the descriptive builder once the grid is too large

* address review: descriptive statistics reads variables one at a time
2026-11-11 05:40:31 -05:00
Dylan M. Taylor 542aa176ce Wildcards: match * and ? patterns in one pass over the text (#3205)
* Wildcards: match * and ? patterns in one pass over the text

After a mismatch the matcher restarted the pattern after the last
star at the next text position, so a long repetitive cell against a
near-matching pattern cost text x pattern comparisons. AutoFilter
custom criteria, Find and Replace all call it once per cell.

wildcard() now runs the pattern as a bit-parallel automaton: one pass
over the text, one step per 64 pattern positions for each character.
Results are unchanged; a test compares it with the old matcher on
every short text and pattern over a small alphabet.

* address review: build wildcard literal masks lazily with a bounded cache

* address review: keep masks of the most frequent wildcard literals past the cache cap
2026-11-11 05:19:24 -05:00
Dylan M. Taylor 67a0b7f325 Security: chart rasterising, spline points and category labels stay bounded (#3204)
* chart: bound scan conversion, spline points and category label bytes

- The rasteriser tested every edge of a path at every sub-sample of every
  covered row. It now keeps an active-edge list (edges sorted by top, joining
  and leaving as the scan passes them), and one rasterize call has a total
  scan-work allowance after which remaining fills are skipped.
- Smoothed lines budgeted interpolated points per run, so gap-separated short
  runs escaped both decimation and the cap. The budget is now shared by all
  runs of a line; once spent, runs are drawn through their data points.
- Category labels copied every text value in full. Labels are clipped to 255
  characters before copying, within an 8 MiB budget per series.

* address review: chart raster and label budget fixes

* address review: bound polyline point count before stroke_paths
2026-11-11 05:07:04 -05:00
Dylan M. Taylor bbe0825404 Security: TEXTJOIN builds its result piece by piece within the text cap (#3202)
* functions: TEXTJOIN builds its result piece by piece within the text cap

TEXTJOIN converted every input and delimiter into an owned String before
joining, so many references to one long shared text copied gigabytes before
the joined length was ever checked. Errors are now found first without
building text (so they still win in argument order), then each piece and
delimiter is converted only as it is appended, stopping at the cap.

* address review: check the cap before copying each text piece
2026-11-11 04:55:21 -05:00
Dylan M. Taylor aa4951fdf4 Security: WORKDAY, PIVOTBY, SORT and the D-functions stop rescanning their input (#3200)
* functions: stop WORKDAY, PIVOTBY, SORT and the D-functions rescanning their input

- WORKDAY/WORKDAY.INTL counted the holidays each step passed by scanning every
  holiday, once per step; a run of consecutive holidays made that quadratic.
  The working-day holidays are kept sorted and counted by binary search.
- PIVOTBY totals rescanned the whole source (rebuilding keys) once per total
  row and column. The grouping pass now also indexes rows by row key and by
  column key, and totals read from those indexes in the same order.
- SORT copied one key per sort_index entry for every row, so a long
  sort_index with repeated columns multiplied the source. Repeats can't break
  a tie their first use left, so only first uses are kept.
- DSUM and the other database functions tested every record against every
  criteria row. A selection whose records x conditions exceed 5e8 is #NUM!,
  and each criteria label's database column is found once, not per cell.

* address review: bounds-check SORT indices before narrowing; empty criteria rows are not charged
2026-11-11 04:36:48 -05:00
Dylan M. Taylor 79fe7b8eb6 Forecast Sheet: count hindcast rows against the work limit (#3199)
* Forecast Sheet: count hindcast rows against the work limit

MAX_WORK bounded history rows x forecast rows, but a Forecast Start
inside the history adds a hindcast row for every history point after
it, each holding the same ETS formulas that refit the model over the
history before the start. Those rows were not counted, so a start in
the middle of 10,000 rows of history built millions of uncharged
model fits per recalculation.

The forecast row cap is now computed from the rows the model is fit
to, after subtracting the hindcast rows; a start so early that the
hindcast alone passes the limit is refused, and the default number of
periods stays within the cap.

* address review: base the in-history forecast cap on the slots the fit range spans

* address review: document the hindcast-aware forecast work limit

* address review: document the fitted-row minimum in the forecast work limit

* address review: say the forecast start row counts toward the hindcast work limit
2026-11-11 04:22:54 -05:00
Dylan M. Taylor ff9f4b8ace Find and Replace: bound the text a replacement builds (#3194)
* Find and Replace: bound the text a replacement builds

Each occurrence copied the whole replacement string, so a cell full of
short matches and a long replacement expanded tens of kilobytes of
input into about a gigabyte, in both the case-sensitive and the
case-insensitive paths.

Replacement now builds the new text only while it stays within
Excel's 32,767 characters per cell (counted as UTF-8 bytes, 4 each at
most) and within 256 MiB for the whole command, checking before each
copy. Past either limit the command fails and changes nothing.

* address review: enforce the 32,767-character limit on replacements and borrow the replacement text
2026-11-11 04:02:22 -05:00
Dylan M. Taylor 5ce63e5fc2 Get Data: refuse an import whose dense rows would be too large (#3193)
* Get Data: refuse an import whose dense rows would be too large

data.getData and data.fromTextCsv built every row densely before
writing it: JSON records were padded to the union of all keys (found
with a quadratic Vec::contains scan), and CSV cells were expanded over
the whole used rectangle. A few sparse records or one far-away CSV
cell therefore allocated billions of slots.

The importer now collects JSON keys with a HashSet, refuses more keys
than a sheet has columns, and checks the projected rows x columns
against MAX_IMPORT_CELLS (10,000,000) before building any row.

* address review: stop collecting JSON keys at the column limit and reject oversized row counts before building rows

* address review: enforce the CSV import cell limit while parsing
2026-11-11 03:48:32 -05:00
Dylan M. Taylor b7fd198e06 Conditional formatting: one scan budget per rule, no thresholds for unsupported colour scales (#3201)
* Conditional formatting: one scan budget per rule, and no thresholds for unsupported colour scales

numbers_in() scanned each of a rule's ranges separately with its own
2,000,000-cell limit, so a rule listing the same (or overlapping)
large range many times multiplied the scan while painting a single
cell. Colour scales also evaluated every stop's threshold, formulas
included, before discarding scales that are not 2- or 3-colour.

- A rule's statistics now share STATS_SCAN_LIMIT across all its ranges,
  and an exact repeat of a range is scanned once.
- A colour scale with a stop count other than 2 or 3 paints nothing
  and evaluates none of its thresholds.

* address review: count stored and spilled cells of sparse ranges, skip statistics for unsupported colour scales

* address review: skip unrelated spills a row at a time in the statistics scan
2026-11-11 03:18:38 -05:00
Valentin KleinandClaude Sonnet 5.5 55f3004cb2 Numfmt: one built-in format table, fewer allocations rendering numbers and General (#817) (#820)
* Numfmt: one built-in format table, no dead render arms, fewer allocations (#817)

S1: the built-in format table lives only in gridcraft-xlsx (file formats do not
depend on numfmt); numfmt's unused copy and its exports are removed, xlsx gets
tests pinning its lookup rules.
S3: drop the render_number arm that printed what the fallback prints; literal
text is appended to the output instead of built as a String per call.
O3: render_number keeps pieces, text-before and literal replacements in one
buffer (Parts), fill_int writes digits left to right, Section stores the date
flags (sub-second digits, calendar, AM/PM) computed at parse time, render_date
writes into one buffer.
O4: significant_digits formats into a stack buffer; format_general_fit re-rounds
the 15 digits per width into reused strings.

An equality test compares format_value, TEXT() and format_general_fit with
digests recorded from the previous implementation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* xlsx test: builtin ids 42 and 44 are written as custom formats like 5-8

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* numfmt: push_str for short month/day names (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 08:57:06 +01:00
FredApps 022074344b Merge pull request #2196 from Higgs32584/fix/2170-shape-font-attr-escape
xlsx: escape shape font typeface attribute (#2170)
2026-11-11 08:12:56 +01:00
FredApps ac4fb45c3f Merge pull request #2326 from nihilist-narwhal/fix/2164-mcp-stdio-line-bound
MCP: the stdio server keeps at most 16 MB of a message (#2164)
2026-11-11 08:12:47 +01:00
FredApps 3d52a20dbd Merge pull request #379 from vangel76/fix/lookup-blank-result
Functions: lookups keep an empty target cell blank (#374)
2026-11-11 08:12:38 +01:00
FredApps 4cc2e21d7c Merge pull request #3382 from vangel76/fix/3321-manage-rules
Manage Rules: scroll the rule list instead of laying out every rule
2026-11-11 08:11:07 +01:00
FredApps 088b43be28 Merge branch 'main' into fix/2170-shape-font-attr-escape 2026-11-11 07:58:02 +01:00
FredApps 942f639fbf Merge branch 'main' into fix/2164-mcp-stdio-line-bound 2026-11-11 07:57:52 +01:00
FredApps a01ff3e7b3 Merge branch 'main' into fix/lookup-blank-result 2026-11-11 07:57:42 +01:00
FredApps b79e992576 Merge branch 'main' into fix/3321-manage-rules 2026-11-11 07:57:35 +01:00
Pranav 0fed26a14f Sandbox: a crate that runs a helper program confined (Landlock and seccomp on Linux, sandbox-exec on macOS)
## Stack

The Python cells work is split by layer. It merges in this order, each with a merge commit rather than a squash. Nobody can switch Python on before #3481.

1. #3475 depends on `main`
2. #3476 depends on `main` **(this pull request)**
3. #3477 depends on [#3475](https://github.com/storytold/gridcraft/pull/3475) and [#3476](https://github.com/storytold/gridcraft/pull/3476)
4. #3478 depends on [#3477](https://github.com/storytold/gridcraft/pull/3477)
5. #3479 depends on [#3478](https://github.com/storytold/gridcraft/pull/3478)
6. #3480 depends on [#3479](https://github.com/storytold/gridcraft/pull/3479)
7. #3481 depends on [#3480](https://github.com/storytold/gridcraft/pull/3480)

## Summary

`crates/sandbox` runs a program that can read only the paths its policy lists, write nothing except `/dev/null`, start no process, and use no network. An application re-executes itself with a launcher flag, closes every inherited descriptor above standard error, applies the confinement, checks that it holds, and only then runs the program. If any of that fails, the program does not run.

## Changes

- **`crates/sandbox`:** the policy, the Linux backend (Landlock, and seccomp that denies all sockets, with an x32 filter), the macOS backend (`/usr/bin/sandbox-exec` with a deny-default profile), the probes and a canary.
- **`apps/gridcraft`, `apps/gridcraft-cli`:** the launcher flag comes first in `main`, before any window or log.
- **`gridcraft-cli sandbox-check`:** says whether this system confines programs, and can run a confined self-test.
- **`docs/sandbox.md`:** what is refused and how.
- **`nix`:** a new dependency on Linux and macOS, for closing descriptors without `unsafe`.

## Compatibility

Windows and FreeBSD have no backend here. There the crate reports that it can't confine, and nothing that needs it runs. On Linux a launch needs Landlock ABI 3, which is Linux 6.2 and later, so an older kernel is treated the same way: this excludes, for example, Debian 12's kernel (6.1) and Ubuntu 22.04's original kernel (5.15). Nothing changes for a process started without the launcher flag.

## Review fixes

- **File metadata on Linux.** Landlock has no right for a file's permissions, owner, extended attributes, times or flags, and the seccomp filter allows what it doesn't name, so a confined program could change them on any file its user owns, outside the policy too. The filter now refuses the `chmod`, `chown`, `setxattr`, `removexattr` and timestamp families with their `*at` variants, by path and by descriptor, and any `ioctl` whose number says it hands the kernel data, as `FS_IOC_SETFLAGS` and `FS_IOC_FSSETXATTR` do on a descriptor opened only to read. A filter allows what it doesn't name, so a call a later kernel adds is allowed until it is named; the newest named is `file_setattr` (Linux 6.17).
- **Truncation on Linux.** Landlock covers truncation only from ABI 3, so on ABI 1 or 2 `os.truncate(path, 0)` emptied any file its user can write, and `open` with `O_RDONLY | O_TRUNC` emptied one it may read. A launch now requires that right. A filter can't take its place: `openat2` hides its flags from seccomp, and refusing `O_TRUNC` would refuse opening `/dev/null` to write, as `open(os.devnull, "w")` does.
- **Standard streams.** They stay open with the rights they were opened with, so a stream that is a file can be written and truncated, and one that is a socket can still send. The page and `launch_command` say so, and that a parent gives the program pipes.
- **The enforcement check, again.** It now also tries to change the canary's permissions and to truncate it, asking for what it already has, so nothing changes if one works.
- **Other processes on Linux.** On a kernel without Landlock's signal scope, a confined program could signal any process of the same user, and `kill(-1, 9)` would reach them all. The seccomp filter now refuses signals to any process but the program itself, whose ID `exec` keeps; changes to the limits, priority or scheduling of any other process; `pidfd_open`, `pidfd_send_signal`, `kcmp`, `get_robust_list`, `migrate_pages` and `move_pages`; every System V shared memory, message queue and semaphore call and every POSIX message queue call, which no path rule covers; and `syslog`.
- **The terminal on Linux.** A program started from a shell could type into its terminal. `TIOCSTI` and `TIOCLINUX` are refused, compared in 32 bits as the kernel reads them, so high bits set on the argument don't get past the rule.
- **The enforcement check.** It now also tries signal 0 on the launcher's parent, which delivers nothing, so `sandbox-check` reports a gap rather than assuming none. The launcher tries the same signal before it confines itself and refuses if that fails, and the check refuses if its parent has changed since, since a parent that refuses anyone would prove nothing. A launcher's arguments must include the current executable, which that check starts; otherwise the file rules refused the start and the check proved nothing about new processes.
- **A refused launch.** The launcher read its arguments only after closing every inherited descriptor, so a refused launch had already closed them. It reads them first.
- **A kernel without Landlock.** The refusal says so in that case too, which the tests that skip on such a machine look for. Only the first ABI's rights being refused, or `ENOSYS` or `EOPNOTSUPP` from creating the ruleset, says so. Any other failure, such as `EINVAL` or too many layers, is reported as what it is and doesn't skip the tests.
- **Folders in the exec list on macOS.** A folder got a rule that matches only the folder, so a launcher under it failed to start where the same policy works on Linux. It now covers what is in it, in both rules.
- **The sandbox page** names only what this layer has: only `gridcraft-cli sandbox-check` starts the launcher here.

## Testing

All 10 steps of `cargo xtask ci` pass on macOS (arm64), and `cargo clippy` passes for this crate for both Linux architectures. The gate on ubuntu-24.04 is the hosted one on this pull request, and it executes the Linux backend through the real binary, including `a_confined_program_leaves_other_processes_alone` and `a_confined_program_changes_no_file`, in which a confined Python tries each refused call on a file inside the policy, one outside it and a descriptor, and the test then compares contents, permissions and modification time.

A separate run on the fork, on Linux 7.0 with Landlock ABI 8, covered the same commit's code with parts of the fix taken out. Without the metadata rules, the launch check refuses with `change the canary's permissions: it worked`. Without those rules or the new checks, `a_confined_program_changes_no_file` fails with `permissions of ...: it worked`. With the Landlock rights limited to ABI 2, as on an older kernel, it fails with `truncate ...: it worked`. A kernel that is actually older than ABI 3, or that has no Landlock at all, has not been run. On macOS, terminal input was checked by hand on a pseudo-terminal: `TIOCSTI` is refused when confined and works when not.

The commits group the change by area for reading. Only the last commit of the branch is expected to build.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * Added sandbox support for running commands with policy-limited file access and restrictions on network and process operations. The sandbox is available on supported Linux and macOS systems; launches are refused if confinement cannot be established or verified.
  * Added the `sandbox-check` command to check sandbox support and enforcement, or run a specified program in the sandbox. It reports when sandboxing is unsupported.
* **Documentation**
  * Added guidance on sandbox behavior, platform support, access restrictions, launch checks, and `sandbox-check` usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:31:42 +01:00
Dylan M. Taylor 6a08240eb6 io: refuse a FIFO before opening it; keep the replaced file's group on save (#3313)
* io: refuse a FIFO before opening it; keep the replaced file's group on save

* address review: create the replacement owner-only, then restore group and mode; never widen it when the group cannot be restored

* address review: keep the old owner and other bits when the group cannot be restored
2026-11-10 18:05:44 -05:00
Dylan M. Taylor a3a98cf745 functions: REGEXREPLACE keeps only the requested occurrence's captures (#3288)
* REGEXREPLACE keeps only the requested occurrence's captures

* address review: do not truncate REGEXREPLACE occurrence on 32-bit targets
2026-11-10 18:05:25 -05:00
Dylan M. Taylor fe39331ac4 engine: Consolidate finds keys through hash maps and refuses an oversized table (#3285)
* engine: Consolidate finds keys through hash maps and refuses an oversized table

data.consolidate searched the keys collected so far for every row and
column key, so 100,000 positional rows cost about five billion
comparisons. It also built every row × column combination without a
size check. Keys are now indexed, and a table over 5 million cells is
refused before it is built.

* address review: count label cells in the consolidate output limit
2026-11-10 18:05:08 -05:00
Dylan M. Taylor a84259d58c ui-egui: embed craft-fonts files only from inside the checkout (#3281)
* ui-egui: embed craft-fonts files only from inside the checkout

The build hook canonicalised each manifest row's file and embedded it
with include_bytes! wherever it led, so an absolute path, a .. path or
a symlink in a craft-fonts checkout could put any readable file of the
build machine into the binary. Manifests and font files must now
resolve inside the canonical checkout directory.

* address review: register the manifest path before validation and keep the Chinese manifest fallback optional
2026-11-10 18:04:49 -05:00
Dylan M. Taylor 85d32c20be engine: show overlong format codes as General without hashing them per cell (#3273)
* engine: show overlong format codes as General without hashing them per cell

The display cache hashed the full format code on every lookup, so one
huge code shared by many cells cost its length for every cell drawn.
A code with more than 4 × 255 bytes can't be within Excel's 255
characters and is now formatted as General straight away.

* address review: keep the original code in the public number_format for overlong formats
2026-11-10 18:04:31 -05:00
Dylan M. Taylor 1deb108a7c xtask: probe keytip commands without file access, and refuse text that could escape generated code (#3269)
* xtask: probe keytip commands without file access, and refuse text that could escape generated code

cargo xtask keytips ran every command keytips.json names, with its
params, under the user's file permissions (file.exportPdf could replace
any file, also during --check). It also wrote target, excel and note
strings into generated Rust without escaping. Probes now run inside the
engine's ScriptSandbox, and those fields may not hold quotes,
backslashes, braces or control characters.

* address review: make ScriptSandbox !Send so it drops on the thread that entered it
2026-11-10 18:04:13 -05:00
Dylan M. Taylor 2c9e2d1eec Go To: index defined names when resolving and listing them (#3206)
Go To resolved every visible name, and each alias inside a name was
looked up with Workbook::name, a linear scan of all names. A workbook
of many short aliases made the Go To list quadratic (or worse along
alias chains) in the number of names.

A Resolver now indexes the names by scope and folded spelling once
per command and remembers, per name, sheet and depth, whether a name
resolves and to how many areas, so listing names resolves each alias
once. Lookups keep the old precedence and matching rules.
2026-11-10 18:03:53 -05:00
Dylan M. Taylor 9c8b4a37ea PDF export: index merged areas when drawing a block (#3203)
draw_block checked every cell against every merge meeting the block,
and the fill, border and text passes searched the merge list again
for each merge start, so an imported sheet with many merge ranges in
a print area cost cells x merges (and merges x merges) comparisons.

The merges meeting a block are now gathered once and swept down the
rows: whether a cell is covered is a BTreeMap lookup among the merges
active on its row, and merge starts are found through a HashMap. A
merge overlapping one kept before it (only possible in a damaged file)
is left out, as Excel does when it repairs the file. Hyperlink
rectangles use the same index instead of scanning all sheet merges.
2026-11-10 18:03:35 -05:00
FredAppsandFredApps a36e4987c1 i18n(uk): translate Chart as Діаграма (#3703)
Use the shared craft-i18n Ukrainian term, distinct from Diagram (Схема).
Also translate the remaining Chart-labelled strings that were still English.

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-10 17:07:10 +01:00
FredAppsandFredApps 457bc7e374 Naming: use Diagram and Decorative Text in the feature catalog (#3622)
* Naming: use Diagram and Decorative Text in the feature catalog

* Naming: align the Chinese decorative text term

* test(naming): guard neutral display labels

* Naming: Diagram no longer repeats the chart word in de, uk and ro

German Schema and Ukrainian Схема match craft-i18n#211; Romanian Schemă
avoids GridCraft's own chart word Diagramă. A catalog test checks every
shipped language translates both labels and that Diagram differs from
Chart/Charts. The display-name guard now handles escaped char literals
and any #[cfg(test)] item, asserts it scanned catalog.rs and a minimum
of files and literals, and tests word boundaries. The catalog test
asserts each ID appears once. Docs carry timestamps and revision history.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-10 16:13:13 +01:00
Harrison Booth 50a9e9ede4 XLS: formulas open as legacy formulas with Excel's implicit @ (#410) (#1367)
.xls formulas are read as legacy formulas with Excel's implicit @ (apply_legacy via read_xls_with), one-cell Ctrl+Shift+Enter arrays keep their range, @ of a 2-D range meets the formula's row and column, and defined-name analyses are shared across the formulas of a pass and replayed exactly.

Closes #410
2026-11-10 15:05:02 +01:00
74ab5cc969 M12: regional format data (Region, 20 regions) (#3048)
* M12: regional format data (Region, 15 regions, Workbook::region)

Adds the data model for Excel-style regional formats, with no behaviour
change: nothing reads it yet.

- core: region.rs with `Region` (separators, grouping, date order, short and
  long date and time codes, month and day names, AM/PM words, currency) and
  the 15 regions en-US, en-GB, en-IN, es-ES, de-DE, fr-FR, it-IT, pt-BR,
  pt-PT, ru-RU, cs-CZ, ja-JP, ko-KR, zh-CN, zh-TW. Values follow Unicode CLDR;
  the deliberate departures (four-digit short-date year, Windows grouping
  spaces) are listed in the module doc. `all`, `find` (strict tag),
  `lookup` (system or browser tag, best effort) and `default_region` (en-US)
  select one; `EN_US` reproduces today's behaviour exactly. Unit tests check
  the tables and the lookups.
- model: `Workbook::region`, a `#[serde(skip)]` runtime field that is never
  saved and defaults to en-US. It is here so that the following changes can
  be reviewed independently of each other.

The region is independent of the interface language and of the formula
language. Builds on this, in order: typed input (core `parse_input_in`),
display (numfmt and engine display), the engine session and `app.region`
command, then the Options UI and the app defaults.

* M12: region data: review fixes (tag extensions, zh territory, month periods)

- lookup stops at an extension or private-use singleton:
  en-GB-u-ca-gregory is en-GB (ca was read as Canada) and
  zh-Hant-u-nu-latn is zh-TW.
- For Chinese an explicit territory wins over the script, so
  zh-Hant-CN is China's conventions, consistent with zh-Hans-TW.
- In regions whose month abbreviations end in a period (ru-RU, fr-FR),
  month_from_name accepts any abbreviation typed with one (мар., июн.);
  elsewhere a period stays outside the name, so en-US is unchanged.

* Region: document where dates and month names follow Windows, not CLDR

* M12: Swiss regions (en-CH, de-CH, fr-CH, it-CH) and nl-NL

The Swiss regions group digits with an apostrophe (CLDR 48) and accept the
typographic one glibc uses; lookup maps CH and LI tags to them. Dutch
(Netherlands) follows CLDR 48.

* Region: table-driven lookup test, under main's cognitive-complexity limit

* Region: French and Italian Liechtenstein tags resolve to the Swiss regions

* Region: credit the Unicode CLDR data under the Unicode License v3 (NOTICE, ATTRIBUTION.md)

* Region: month_from_name takes one word; the currency format has the currency's own decimals

---------

Co-authored-by: quantmind-br <dsrodrigues20@gmail.com>
Co-authored-by: quantforge-br <218010578+quantforge-br@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 13:54:53 +01:00
RockerHXandFredApps ec12eba83e UI.1265: align CJK fallback text with the Latin baseline (#3081)
* UI.1265: align CJK fallback text with the Latin baseline

Use epaint's existing baseline alignment for system CJK fallback faces
and embedded faces tagged with CJK scripts. Mixed CJK, Latin and digits
then share a baseline in cells, editors and window titles.

Add regression coverage for zoom, font styles, wrapping, fixed-height
clipping, vertical alignment, editor height and title truncation.

Refs #1265, #2621.

* UI.1265: assert title baselines and document visual validation

* UI.1265: add baseline comparison and review validation

* UI.1265: adapt tooltip regression after upstream merge

The upstream shortcut update uses the runtime OS to choose tooltip text.
Use the harness OS for the existing split-button test expectation too.

Validation: cargo xtask ci; all 10 steps pass; 3,002 tests passed, 1 ignored, no exclusions.

* UI.1265: propagate CJK fallback to installed fonts

* UI.1265: align fallback baselines across cells

* UI.1265: align wrapped and unwrapped cell text

* UI.1265: preserve blank lines in wrapped cell alignment

* UI.1265: preserve centered fallback placement

* UI.1265: find CJK cell text by its visible bounds

Use the overlap of text ink, clip rect and cell rect to locate painted text
instead of testing a fixed offset from the galley origin. Baseline-aligned
fallbacks can move that origin above the cell on Ubuntu.

Keep the existing baseline and wrapping assertions unchanged.

Validation: cargo xtask ci on macOS; all 3 cjk_baseline tests on Ubuntu 24.04 x86_64.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 12:44:19 +01:00
ca39b5ab9f XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel (#417)
* XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel; implied @ saved back without cm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* XLSX: long operator chains analysed in full and unfinished legacy analysis leaves the formula unchanged (never saved as SINGLE); @ of a union keeps its parentheses; IF's condition intersected inside SUMPRODUCT

* XLSX: a legacy union already in parentheses saves back without a second pair (never as SINGLE); a name's definition analysed as an array throughout, inserting no @

* XLSX: drop the unreachable keep-unions retry; an @ of a bare union saves as a dynamic formula

* XLSX: an @ keeps parentheses the reader would not put back (redundant ones, a union's)

* XLSX: a redundant @ keeps a legacy formula legacy; IFERROR and IFNA intersected inside SUMPRODUCT; file parentheses and unfinished analyses saved as read

An edit could leave an implied @ on a single cell: deleting rows 2:3 under the legacy SQRT(A1:A3), read as SQRT(@A1:A3), gives SQRT(@A1:A1), and a name redefined to one cell does the same to SQRT(@Rng). The writer kept an @ only when re-analysing the formula without it put the same @ back, which it no longer does for one cell, so the formula was saved as SQRT(_xlfn.SINGLE(A1:A1)) with cm, readable only by dynamic-array Excel. The writer now analyses the formula with its own @ in place: an @ is accepted when the analysis implies it at that position or when its operand is a single value there (such an @ changes nothing), and any @ inserted elsewhere or any other @ still makes the formula dynamic. SQRT(@A1:A1) saves as SQRT(A1:A1), SQRT(@A1) as SQRT(A1) and @SUM(A1:A3) as SUM(A1:A3), all without cm; SUMPRODUCT(@SQRT(A1:A3)) stays dynamic because SQRT(A1:A3) is an array there.

Legacy Excel intersects IFERROR's and IFNA's arguments inside SUMPRODUCT unless the formula is entered with Ctrl+Shift+Enter, like IF's condition: =SUMPRODUCT(IFERROR(VALUE(C19:C21),0)) returns one row's value or #VALUE!, and the accepted fix is CSE or N(+range) (https://stackoverflow.com/questions/59228707). They now get the same single-value parameter class as IF's condition (Param::Single), so the legacy SUMPRODUCT(IFERROR(A1:A3/A1:A3,0)) reads as SUMPRODUCT(IFERROR(@A1:A3/@A1:A3,0)), and a typed SUMPRODUCT(IFERROR(VALUE(A1:A3),0)), which dynamic-array Excel evaluates as an array, is saved with cm. IF's result arguments are left as arrays: in https://stackoverflow.com/questions/29682328, a non-CSE =SUMPRODUCT(--(B4=IF(NOT($C$4:$C$11),$B$4:$B$11))) in row 4 returns 2 (both "apple" rows of B4:B11 counted, so the result branch was not intersected), 0 in a row where C is TRUE and #VALUE! outside rows 4:11 (the condition was intersected).

The reader's @ in front of a whole-formula array result took no extra parentheses when the file already had them, so ({1,2}+1) and {1,2}+1 both read as @({1,2}+1), and the writer dropped the file's parentheses. An operand already in parentheses now gets a second pair, so ({1,2}+1) saves as written.

A legacy formula the analysis could not finish (node budget, nesting) was read unchanged, evaluated as a dynamic-array formula and saved with cm, so Excel spilled it. Names were analysed again at every use, so a 399-term name used six times exhausted the 4096-node budget; their kinds are now remembered per formula, and the budget is 16384 nodes. The nesting limit is 128, so Excel's 64 levels of nested functions are analysed. A formula that still exceeds the limits is saved as it was read, without cm.

* XLSX: an implied @ is dropped and any other @ of a single value kept as _xlfn.SINGLE in a legacy formula (as Excel writes it); a legacy formula the analysis cannot finish or whose semantics it does not know opens with a whole-formula @ and never spills; analysis depth follows the parser's limit

* XLSX: a legacy union given to a function's single-value parameter keeps its implied @, so it calculates to #VALUE! instead of spilling and saves as read; checked on a calculated workbook

* XLSX: only a one-cell range an @ applies to counts as one an edit shrank; a reading past the parser's nesting limit leaves the formula as read, and the writer saves the text a formula it cannot analyse was read from

* XLSX: a formula nested past the parser's limit for its reading is left as read (documented: it may spill); test covers it

* XLSX: a name's definition counts its one-cell ranges as collapsed only when it is itself a reference, so CHOOSE's index or IF's condition in it keeps the name legacy

* XLSX: an @ on a name or one-cell range counts as implied when ranges an edit may have shrunk, anywhere the name returns them (CHOOSE's choices, parentheses), make it so; deleting rows keeps such formulas legacy

* XLSX: the collapsed-range retry of an @ runs only when the first pass reached a one-cell range written as a range (tracked through cached names)

* docs: describe unfinished formula export fallback

* docs: the unfinished-analysis fallback differs for formulas with an explicit @

* Preserve main formula analyzer fixes

* Order file-format revision history

---------

Co-authored-by: neilcawse <8085959+neilcawse@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Local contributor <contributor@localhost>
2026-11-10 12:43:07 +01:00
e9addf52fe Arabic bidi layout and cell reading direction in the grid and editors (#947)
* M12.AR4: preserve Arabic logical text in bidi display and editing

* M12.AR4: drop the stale "no bidi" note from the RTL sheet status

* M12.AR4: open the Arabic editors and place the caret through key and pointer events in tests

* M12.AR4: skip the conditional-format lookup in the formula bar unless a rule sets reading order

* M12.AR4: test that the caret after typed Arabic is drawn at the text's left end (#1070)

* M12.AR4: pass the theme body font in the reading-order style test

* M12.AR4: adapt the Arabic UI tests to egui 0.37 (row direction, editor ids, Ctrl+End to the logical end)

* M12.AR4: read the active sheet through Workbook::active_sheet() in the formula bar

main made `Workbook::active_sheet` an `ActiveSheet` (shared between windows on one
workbook); the formula bar's reading-order lookup takes the index from the accessor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR4: keep explicit Left on wrapped right-to-left cells and trailing spaces in the editor

Wrapped cell text lays out every row from the left when the painted alignment is Left, instead of aligning right-to-left rows to their start. The cell and formula bar editor keep trailing whitespace in their layout so a right-to-left entry ending in a space keeps the caret inside the text.

* M12.AR4: keep Right and General on right-to-left sheets aligned right in wrapped cells

* M12.AR4: keep right-aligned Arabic with a marked first letter inside its cell

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:55:43 +01:00
cda767b49b Arabic font fallback in cells, the formula bar and the web (#931)
* M12.AR2: Arabic font fallback in cells and the interface (#229)

* M12.AR2: reserve the preferred Arabic file only once it has loaded

load_fallback treated fontconfig's Arabic file as already loaded even
when load_preferred_arabic could not load it, so a system Arabic face
from the same file was skipped and the chains could end up with no
Arabic system face. The file is now reserved only when the preferred
face is in the font set; the candidate loop moves to load_faces so a
test can drive it with a fixture file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR2: say where fontconfig's Arabic choice applies

fontconfig's Arabic face leads the Arabic fallbacks, so it draws Arabic
where the UI or cell font has none. A UI font with Arabic of its own,
such as DejaVu Sans in the Flatpak, still draws interface Arabic (#229),
which is left for a follow-up. The docs and the test's doc comment no
longer say the choice comes first in every chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:54:55 +01:00
c35234e5f5 Number formats: honour the B1/B2 calendar prefixes (Hijri dates for B2) (#1918)
* M12.AR6: honour the B1/B2 calendar prefixes in number formats (Hijri for B2)

* M12.AR6: match Excel's B1/B2 behaviour measured in review

Changes from a reviewer's Excel run (TEXT and Range.NumberFormat):
- A [$-...] locale tag before B2 keeps the date Gregorian (prefix still
  hidden); B2[$-401] stays Hijri.
- Any other unquoted B and digit (B3dd, dd/mm/yyyyB2, dd B2 mm), and B2
  before a number format (B20.00), make the code invalid: TEXT gives
  #VALUE!, a cell shows General, as for a code over 255 characters.
- Under B2, ddd and dddd show the Arabic weekday name (full name for both).
- Serial 60 has no Hijri date: TEXT gives #VALUE!, a cell shows ####.
- Serial 0 shows as serial 1 (29/08/1317).

Also fixes a break from merging main: days_from_civil now returns 0 for
day 0 and 29 February 1900, which sent serials 0 and 60 to 1970
(23/10/1389) under B2. The Hijri day count no longer passes those dates.

Docs: localization-parity states the behaviour, including the one
measured case not matched (mmmm next to dddd prints the weekday in Excel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: Hijri leap year in cycle year 15, not 16, as Excel shows

A reviewer compared Excel's B2yyyy-mm-dd on 43,512 serials with this
formula: it disagreed by one day on 1,372, all in cycle years 15 and 16,
and moving the leap year from 16 to 15 left no disagreement. The leap
years are now 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 (still 10,631
days a cycle).

The count of leap years before a year becomes (4 + 11y) div 30; it
differs from the old (3 + 11y) div 30 only when y = 16 (mod 30), which is
exactly the move from 16 to 15. The inverse's year estimate can now be one
year off at a boundary and is corrected by one step.

Tests: Excel values for 2 March 2005 (1426-01-21) and 29 January 2006
(1426-12-29) plus the 1425/1426 year boundaries they imply, the reviewer's
other anchors, and exhaustive checks: year, month and 30-year cycle
lengths for Hijri years 1 to 10,000; day -> Hijri -> day round trip and
day-by-day succession for every day from 1 Muharram 1 AH to one cycle past
31 December 9999; every 1900-system serial 1 to 2,958,465 (except 60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: match B1/B2 details checked in Excel 16.0 build 20527

Compared GridCraft with Excel 16.0 (build 20527, en-US) through COM: TEXT
with B2dd/mm/yyyy on every serial from 1 to 2,958,465 (no difference), and
about 1,750 format/value pairs through both a cell's NumberFormat and TEXT.
Fixed what differed and is in scope:

- Two-word Hijri month names use a no-break space (U+00A0), as Excel does.
- B1 before a number format is rejected like B2 (B10.00, B1#,##0).
- Prefix runs: a repeated prefix is accepted (B2B2, B2b2, [Red]B2B2, B1B1);
  a mixed one (B1B2, B2B1), more digits (B12, B21, B02), a prefix after a
  tag or space (B2[Red]B2, B2 B2) and an escaped \B2 are rejected.
- Serial 60 under B2 is #VALUE! for any date or clock part (hh:mm, ss,
  AM/PM), not only date parts; [h] and literal-only sections still show.

Regression tests use the Excel values (all 12 month and 7 weekday names,
the accepted and rejected codes, serial 60). Not changed, listed in the
docs: Excel's weekday/month-name mix-ups when ddd(d) and mmm(m) share a B2
section, weekday-only B2 sections (real weekday for serials 0-59, #VALUE!
for the last days of 9999), and Buddhist-year b/bb/bbbb tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:54:05 +01:00
004523c286 AutoFit: estimate Arabic text width from measured glyph widths (#1933)
* M12.AR12: estimate Arabic text width from measured glyph widths in AutoFit

* M12.AR12: treat U+0897–089F as marks and U+08E2 as visible; size the honorific ligatures (review)

* M12.AR12: size the remaining honorific ligatures like their siblings (review)

U+FD4C-FD4F (Unicode 14) and U+FDC8-FDCF (FDCF Unicode 14, FDC8-FDCE
Unicode 17) are honorific word ligatures, not letters, but fell into
the 0.48 em letter arm. Give them the 1.6 em of the measured
U+FD40-FD4B honorifics and assert each range's endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR12: size Arabic-Indic digits like ASCII digits and the remaining honorific ligatures (review)

Arabic-Indic (U+0660-0669) and Extended Arabic-Indic (U+06F0-06F9)
digits measure 0.53-0.57 em in Noto Sans Arabic, Arial, Tahoma and
Times New Roman, the same as each font's ASCII digits, so they get
the ASCII digit estimate (0.56) instead of the letter estimate.
Arabic punctuation measures 0.17-0.50 em, so it keeps 0.48 rather
than falling back to 1 em.

U+FBC3-FBD2 and U+FD90-FD91 are honorific word ligatures like
U+FD40-FD4F and get the same 1.6 em.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:52:55 +01:00
517a4b9256 Sort: order Arabic text by its letters, not its vowel marks or digit script (#1940)
* M12.AR7: sort Arabic text by its letters, not its vowel marks or digit script

* M12.AR7: order marked Arabic text the way Excel does

Checked compare_text against Excel 16.0 build 20527 (en-US) via COM:
Range.Sort, =SORT() and the < / = operators over ~300 strings. Fixes the
mismatches inside this change's scope:

- The tie-break compared raw code points, so an RLM-prefixed word sorted
  after every vowelled form of it, "‏12" after "١٢", and a tatweel
  form after a shadda form. Ignorable characters are now skipped at the
  tie-break too.
- Marks are compared letter by letter (an unmarked letter first), so a
  mark on an earlier letter outranks the digit or letter after it, as in
  Excel ("ب١" < "بَ1", "بپ" < "بَپ").
- Marks rank in Excel's measured order (sukun before shadda), and the
  Quranic marks Excel skips (U+0610–061A, U+06D8–06ED except 06DD/06DE/
  06DF/06E0/06E5/06E6/06E9) are skipped by letter order.
- Excel also ignores bidi embeddings/overrides/isolates, BOM, soft hyphen,
  CGJ and word joiner; they now are ignored too (ZWSP is not, as in Excel).

Equality is unchanged. Regression tests use the orders Excel produced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR7: compare the mark level of Arabic text without allocating

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-09 15:49:26 +01:00
Valentin KleinandClaude Opus 5.5 7102429a9a perf(grid): skip hidden row runs and keep sheet geometry and CF statistics between frames (#3580) (#3629)
A sheet whose filter hides a million rows walked every hidden row
several times per frame (cells, gridlines, headers), rebuilt the row
index, the outline and the used range every frame, and a conditional
format over a whole column rescanned it every frame for its statistics.

- LineIndex::next_visible / visible step over a run of hidden lines in
  one O(log n) search; the grid's row and column loops use them.
- The grid keeps the active sheet's line index, outlines, used range and
  CfCache between frames, tied to the sheet by a Weak: any change goes
  through Arc::make_mut, which moves or copies a sheet that has a weak
  reference, so the cache can't outlive a change.
- Scroll offsets for the scrollbars and the scroll limit come from the
  line index instead of walking every custom row.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:46:53 +01:00
Dylan M. Taylor 76416d4869 Merge pull request #3641 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:56:18 -05:00
dotCooCoo 3004a05e46 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:45:34 -08:00
Dylan M. Taylor 01033f2c8f Merge pull request #3621 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:47 -05:00
dotCooCoo 99bbd237fc Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-update, craft-window, craft-print and craft-ui move from craft-libs
c5a1cbfb to c7c84230, a branch with storytold/craft-libs#687 and #696
merged into craft-libs main.

- The check result window shows whenever the updater's status is not
  idle, so the first start after an update reports that it installed or
  why it failed. Restart to Update asks the app to quit once nothing is
  unsaved.
- An update's close request quits the app (Quit asks about each unsaved
  workbook), so every workbook window closes.
- on_exit starts the install helper of a ready update before the macOS
  process exit.
2026-11-08 11:53:25 -08:00
vangel76andClaude Opus 5.5 642fa97fa5 fix(functions): a blank element of a lifted argument is given, not omitted
CEILING.MATH(6.7,VLOOKUP({"k","k"},A1:B1,2,FALSE)) with B1 empty now gives 0 per
element, as the scalar call does, instead of treating the significance as left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:19:05 +01:00
vangel76andClaude Opus 5.5 63150810b3 fix(functions): a blank lookup given as an argument is not an omitted one
FILTER(1,FALSE,VLOOKUP(...)) with a blank lookup result returned #CALC!
because util::has read the blank value as a left-out argument. Arguments
written in the formula that compute a blank are now marked as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:27:08 +01:00
FredApps 2c58c76914 Merge pull request #2696 from vangel76/perf/calc-spill-unblock-2684
perf(calc): find formulas a cleared cell may unblock from a set of #SPILL! cells
2026-11-08 13:01:25 +01:00
FredApps c000e1b3ff Merge pull request #2694 from vangel76/perf/calc-spill-index-2683
perf(calc): index spill areas so cell and range reads don't scan every spill
2026-11-08 12:58:44 +01:00
vangel76andClaude Opus 5.5 dae5b52d78 Merge upstream/main into fix/lookup-blank-result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:55:54 +01:00
vangel76andClaude Opus 5.5 684f051f0d Manage Rules: test reaching rules by scrolling down and sideways
Scroll the 3,000-rule list to a row that was off screen and move and
delete that rule through its own buttons; give one rule a label of 200
ranges whose buttons start outside the dialog, scroll sideways, then move
and delete it. Each test checks the intended rule changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:51:27 +01:00
FredApps 21920d3d61 Merge pull request #3297: complete equation input caps with #3328
Equations: refuse macro definitions and cap matrix cells
2026-11-08 12:47:35 +01:00
FredApps a2509bdf8b Merge newline matrix-cap fix into prerequisite (#3297, #3328)
Keep the complete reviewed matrix bound together so main never receives the known newline-row bypass. This merge tree is identical to the validated #3328 head 83670dfe3a.
2026-11-08 12:33:22 +01:00
vangel76andClaude Opus 5.5 0f5a860d74 Merge perf/calc-spill-index-2683 (with upstream/main) into perf/calc-spill-unblock-2684
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:23:30 +01:00
vangel76andClaude Opus 5.5 91bd22635e Merge upstream/main into fix/lookup-blank-result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:23:30 +01:00
vangel76andClaude Opus 5.5 17131fb0e5 Merge upstream/main into perf/calc-spill-index-2683
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:18:31 +01:00
FredApps 94a8fb084a Merge pull request #1152 from vangel76/perf/chart-measure-allocs
Charts: label fitting and legend measuring avoid hundreds of text layouts per render (#1149)
2026-11-08 12:08:59 +01:00
FredApps fca22f6e93 Merge pull request #1155 from vangel76/fix/chart-work-budgets
Charts: work and memory are bounded by series x points; ranges are clipped before evaluation (#1147)
2026-11-08 12:08:35 +01:00
FredApps 300b074894 Merge pull request #3325 from vangel76/fix/binom-poisson-accuracy
BINOM.DIST, POISSON.DIST: Loader mass and recurrence sums, error 1e-11 -> 1e-14 (#3324)
2026-11-08 12:08:16 +01:00
FredApps 9dd3b51bc6 Merge pull request #3383 from vangel76/fix/3350-ctrl-tab-arrows
Tabs: Ctrl-click on the sheet arrows jumps to the first or last sheet
2026-11-08 12:07:28 +01:00
FredApps 4a93722ea6 Merge pull request #2691 from vangel76/perf/calc-range-memo-2681
perf(calc): SUM/COUNT/AVERAGE over big ranges reuse constant-only results
2026-11-08 12:06:13 +01:00
FredApps 6338cef450 Merge pull request #3578 from vangel76/fix/3577-json-formula-trees
Cache formula trees when a workbook is read from JSON
2026-11-08 12:05:30 +01:00
FredApps 24119e49fe Merge pull request #3581 from vangel76/perf/3575-store-shifts
Move whole rows when inserting or deleting rows and columns
2026-11-08 12:05:00 +01:00
FredApps cbd0879c44 Merge pull request #3591 from vangel76/perf/engine-adjust-if-changed
Structural edits rewrite only the formulas they change
2026-11-08 12:03:48 +01:00
FredApps 0057fc00c9 Merge pull request #3579 from vangel76/perf/adjust-unchanged-check
Formulas: adjust_if_changed reports when a structural edit leaves a formula alone
2026-11-08 12:02:12 +01:00
vangel76andClaude Opus 5.5 9ab297ced1 perf(engine): rewrite only the formulas a structural edit changes
rewrite_all_formulas borrows each tree with Formula::with_expr and calls
adjust_if_changed, so a formula the edit leaves alone is neither copied
nor printed, and a changed one is printed once. A legacy array whose
range moves while its formula stays the same still gets the new range.

Sheet rename and delete compare the adjusted tree with the old one
instead of printed text, and replace only formulas that changed. Delete
no longer rebuilds every formula in the workbook, so unrelated formulas
keep their array range and dynamic-array mark.

A stored non-canonical formula text is no longer re-printed by an edit
that does not change the formula; a test pins this.

Part of #3576

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:06:21 +01:00
vangel76andClaude Opus 5.5 2ee261c0e8 perf(model): move whole rows when inserting or deleting rows and columns (#3575)
CellStore::shift_rows, shift_cols, shift_cols_in_rows and take_range removed
and re-set every affected cell one by one. They now take whole row maps (or
the split-off part of one) out of the bands and put them back at their new
position, so a row shift costs a few map operations per row instead of a
band lookup, a remove and a set per cell. take_range does this when the
block reaches the last column.

Behaviour is unchanged, including blank cells left behind by get_mut, cells off the grid (they
are still dropped, and still clear the cell they land on), cells shifted off
the sheet, and copy-on-write: bands without moved cells stay shared with undo
snapshots. A differential test runs 12,000 random operations against the old
per-cell code and compares contents, length, iteration order and band sharing.

shift_rows_in_cols, and take_range on a block with columns to its right, keep
moving cell by cell: the row-map version measured no faster there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:51:57 +01:00
vangel76andClaude Opus 5.5 7cb0088361 perf(formula): adjust_if_changed skips formulas a structural edit leaves alone
adjust had no way to report that an edit does not affect a formula, so
callers copied, rebuilt and printed every formula in the workbook.
adjust_if_changed borrows the formula and returns None when nothing
moves. Its check reuses adjust_ref on each reference into the edited
sheet, on a copy without the sheet name, so it cannot disagree with
adjust. adjust_ref and shift_ref take the reference by value and no
longer clone sheet names.

An integration test keeps the previous implementation as an oracle
over every edit kind and a randomized property test.

Fixes #3576

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:04:24 +01:00
vangel76andClaude Opus 5.5 4bba98fd50 perf(model): cache formula trees when reading a workbook from JSON
A Formula read from JSON had no cached tree (`expr` is not saved), so
every graph rebuild and every recalculation parsed each formula again.
Reading one now parses its text once and keeps the tree, as
Formula::new does. The saved text is kept as written.

Fixes #3577

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:57:51 +01:00
640fbf22f6 fix(edit): paste, Ctrl+Enter, Fill and Copy skip rows a filter hides (#1101) (#1175)
* Paste, Ctrl+Enter and Fill skip rows hidden by a filter (#1101)

Repeating one cell or row down a filtered selection (paste of a single
cell or row, Ctrl+Enter, Fill Down/Up/Right/Left) now leaves the rows an
applied filter hides alone, as Excel does. A taller pasted block still
goes straight down, and rows hidden by hand are still written, also as
in Excel. Each case was measured in Excel for Microsoft 365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr

* Copying a filtered range takes only the rows showing (#1101)

A copied range with rows hidden by an applied filter is split into its
runs of showing rows and packed like a multi-area copy (formulas as
values), so pasting it inside GridCraft no longer brings the hidden rows
back. One run is copied as a plain range, keeping formulas. Cut still
moves every row. All as measured in Excel for Microsoft 365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2bJ3LxrZ6YnmppgrfwHwr

* Find the rows a filter hides without rescanning every table per row

filtered_rows asks the sheet for its filter row spans once instead of
checking every table for each hidden row.

* Paste over filtered rows leaves their validation rules alone

When one row is tiled over filtered rows, paste_validations no longer
clears or copies rules on the rows the filter hides.

* Filtered copy packs links and validation with the rows showing

slide_together now moves hyperlinks and validation rules with their rows, and a
filtered whole-row copy records the packed rows as its selection so Insert
Copied Cells inserts only the rows copied.

* Group validation paste options and format the new tests

* Pack a filtered copy in one pass over notes, merges, links and spills

slide_together finds each entry's area by search instead of scanning every map
once per area, so a filter leaving thousands of separate runs no longer costs
runs times map size on every copy.

---------

Co-authored-by: Richard <richard1912@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar <hi@skylar.cc>
2026-11-07 19:30:06 -06:00
vangel76andClaude Sonnet 5.5 865a7f6a06 Merge upstream/main into perf/calc-spill-unblock-2684
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:09:51 +01:00
vangel76andClaude Sonnet 5.5 1458f45214 Merge upstream/main into perf/calc-spill-index-2683
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:09:31 +01:00
vangel76andClaude Sonnet 5.5 05a5845234 Merge upstream/main into perf/calc-range-memo-2681
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:38:18 +01:00
vangel76andClaude Sonnet 5.5 762be28e29 Merge upstream/main into fix/math-macro-defs-and-matrix-cells
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:38:03 +01:00
vangel76andClaude Sonnet 5.5 8e5bcf1575 Merge upstream/main into fix/binom-poisson-accuracy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:37:33 +01:00
vangel76andClaude Sonnet 5.5 83670dfe3a Merge upstream/main into fix/math-cap-lookahead
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:37:18 +01:00
vangel76andClaude Sonnet 5.5 019033e095 Merge upstream/main into fix/3321-manage-rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:37:04 +01:00
vangel76andClaude Sonnet 5.5 93dab0c420 Merge upstream/main into fix/3350-ctrl-tab-arrows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:36:49 +01:00
FredApps dea064b857 Merge pull request #3547 from FredApps/fix/chart-table-series-index-1150
fix(chart): pass series index to data-table keys
2026-11-07 17:47:58 +01:00
vangel76andClaude Sonnet 5.5 9354fdb0c4 Chart: fit labels with a line break by a linear scan
The width of a prefix plus an ellipsis is not monotone when the text has a
line break, so the binary search could drop a label that fits. Also pass the
series index to plot_kind in the data table (upstream/main does not compile
without it).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:37:20 +01:00
vangel76 5f84fadaf5 Merge remote-tracking branch 'upstream/main' into c1152 2026-11-07 17:32:43 +01:00
vangel76 9ba79c3e38 fmt 2026-11-07 17:31:41 +01:00
vangel76andClaude Sonnet 5.5 6a7cf9cf66 Chart: bound the category axis by the point cap, not the raw cell count
Read only the cells the category levels use and cut the category dimension
at the per-series cap, so a multi-level axis keeps all its levels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:31:27 +01:00
vangel76andClaude Sonnet 5.5 c35878948a Merge upstream/main into fix/chart-work-budgets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:31:27 +01:00
vangel76andClaude Sonnet 5.5 42370639e0 Merge upstream/main into perf/calc-range-memo-2681
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:29:04 +01:00
FredApps 6b19e445f4 fix(chart): pass series index to data-table keys (#1150) 2026-11-07 17:27:25 +01:00
vangel76andClaude Sonnet 5.5 05cffdceb1 Tab strip: test the Previous arrow reveal for the already active first tab
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:27:07 +01:00
vangel76andClaude Sonnet 5.5 87cb0f11d2 Tab strip: reveal the edge tab after a repeated Ctrl-click on an arrow
When the edge sheet was already active nothing changed, so the strip was not
scrolled back to it. A Ctrl-click on the arrow now asks for the reveal itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:22:54 +01:00
vangel76andClaude Sonnet 5.5 12f69f99bf BINOM.DIST: keep small-k masses whose complement power underflows
The small-k product also needs (1 - p)^(n - k) to stay a normal number;
otherwise it returned 0 for BINOM.DIST(15,1100,0.5,FALSE) (exact
2.1367646606291324e-298) and the cumulative form built on it. Those cases
now use the logarithmic formula. Reference grid gets k = 10..25 at n = 1100.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:20:53 +01:00
vangel76andClaude Sonnet 5.5 225496e544 Math: refuse computed environment names in the alignat pair cap
A name such as align\@char{97}t reads as alignat to the parser but split at
the first } looked like another environment, so the pair limit was skipped.
Names that contain a backslash or brace, or do not close in the window, are
now refused like an unreadable count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:19:20 +01:00
vangel76 c125ce4780 Merge branch 'fa-3297' into fa-3328 2026-11-07 17:19:20 +01:00
vangel76andClaude Sonnet 5.5 d7c0158f0a Manage Rules: let the rule list scroll sideways
A long range list pushed the move and Delete buttons out of the vertical-only
scroll area, where they could not be clicked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:08:44 +01:00
vangel76andClaude Sonnet 5.5 76002fe0bd Reuse remembered range sums for every COUNT block
COUNT never reads the running sum, so the zero-sum gate that keeps SUM
and AVERAGE additions in order does not apply to it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:58:19 +01:00
FredApps 505cae34c8 Merge pull request #1150 from vangel76/fix/chart-combo-logic-cleanup
Charts: one combo plot-kind function, no dead scale helpers (#1146)
2026-11-07 16:52:46 +01:00
FredApps ee1b5df3db Merge pull request #2693 from vangel76/perf/calc-expr-arc-2685
calc: rebuild the dependency graph from shared syntax trees
2026-11-07 16:51:36 +01:00
FredApps cbd765332c Merge pull request #2686 from vangel76/perf/stat-select-2679
perf: select instead of sort in MEDIAN, PERCENTILE, QUARTILE, LARGE, SMALL
2026-11-07 16:51:02 +01:00
FredApps b9e5e0ff89 Merge pull request #3309 from vangel76/fix-autosum-gap-3291
fix(autosum): stop the guessed range at a blank cell
2026-11-07 16:50:28 +01:00
FredApps 66bb6ae239 Merge pull request #3331 from vangel76/contributors-vangel76-names
contributors: set names for vangel76
2026-11-07 16:49:24 +01:00
vangel76andClaude Sonnet 5.5 bc1625248e Chart: cap category levels in the clean-data check, recompute overflowing moving-average windows, keep disjoint stacked hairline spans apart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:25:23 +01:00
FredApps 4fc2c9c3b3 Merge pull request #3524 from helenkwok/web/page-api
gridcraft-web: window.gridcraft, the page command API from craft-web-api
2026-11-07 15:38:37 +01:00
FredApps 546a546943 Merge pull request #3508 from ZIDAN44/fix/3484-ctrl-shift-arrow-active-line
Selection: Ctrl+Shift+Arrow searches the active cell's line (#3484)
2026-11-07 15:38:30 +01:00
FredApps 8774fa45fb Merge pull request #3507 from ZIDAN44/fix/3485-scroll-per-window
View: a sheet keeps its own scroll position when sheets move (#3485)
2026-11-07 15:38:25 +01:00
FredApps 8325dbe50e Merge pull request #3506 from ZIDAN44/fix/3446-sort-dialog-column-label
Sort: the closed Sort by box names the column, as its list does (#3446)
2026-11-07 15:38:20 +01:00
Helen Kwok fb17d0d1d5 gridcraft-web: say a call the app already took still finishes after a timeout 2026-11-07 10:28:03 +08:00
Helen Kwok 89ec06f041 gridcraft-web: window.gridcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. A call the page gave up on is released, so the app skips it instead of running it late. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:03:07 +08:00
Zinadin ZidanandOlivier 20473a4fae Selection: Ctrl+Shift+Arrow searches the active cell's line (#3484)
Ctrl+Shift+Arrow searched from the selection's moving corner, so after
Ctrl+Shift+Right from A2 the next Ctrl+Shift+Down searched the far column
and ran to the last row. As in Excel, it now searches the active cell's
column (Up/Down) or row (Left/Right) from the edge being moved, and moves
only that edge. A diagonal move from command params keeps the old jump.

Co-Authored-By: Olivier <5681030+tutis@users.noreply.github.com>
2026-11-07 00:27:08 +06:00
Zinadin ZidanandOlivier 1688b43ae2 View: a sheet keeps its own scroll position when sheets move (#3485)
Scroll positions were stored by (window, sheet index), and nothing re-keyed
them when sheets were inserted, deleted or moved. A new PivotTable sheet,
which goes in front of its source, opened at the source's position, and the
source took over whatever was stored at its new index. Positions are now
keyed by (window, sheet uid), so they follow the sheet through inserts,
deletes, moves and undo. A closed window's positions are dropped. Saving the
active cell still happens when no window is active.

Co-Authored-By: Olivier <5681030+tutis@users.noreply.github.com>
2026-11-07 00:27:07 +06:00
Zinadin Zidan 7d3ede854f Sort: the closed Sort by box names the column, as its list does (#3446)
The Sort dialog's closed "Sort by" box showed only the stored column
letter ("B") while its open list showed "B: Revenue". It now looks the
letter up in the dialog's column labels and shows the same label, falling
back to the bare letter for a column outside the data block.
2026-11-07 00:27:07 +06:00
FredApps 6a8eb4ff42 Merge pull request #3487 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:10 +01:00
FredApps 543497acff docs: preserve local guidance in house-rules rollout 2026-11-06 12:37:04 +01:00
FredApps 620a91904f Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
ff35c6b89d xlsx: stop memory amplification from declared sizes (#2173) (#2197)
* xlsx: stop memory amplification from declared sizes (#2173)

Hyperlinks: drop targets over 8,192 bytes before copying them into each cell, cut ScreenTips to 255 characters, and count the 10,000-cell cap across all <hyperlinks> blocks of a sheet. Conditional formats: cap the sqref copies made by extra rules at 1M ranges per sheet. Styles: build each custom number format once and share its Arc; cut font names to 255 characters. Shared formulas: keep cached values for dependents of over-long masters, and charge each expansion against a 2 GiB per-workbook budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import the hyperlink caps in sheet_read/parts after the merge

Upstream switched parts.rs to an explicit super:: import list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: charge conditional-format ranges per sheet; cap number format codes (#2173)

- read_cf keeps a cumulative per-sheet count of the range cells retained
  rules hold, instead of multiplying the current block by the rule count,
  and charges only rules that are kept.
- Custom and inline number format codes longer than 255 characters (Excel's
  limit) are dropped with a warning; their cells show General.
- The shared NumFmt map is built only for ids a cell or differential
  format refers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: keep over-long number format codes as stored (#2173)

an_overlong_number_format_code_does_not_stop_the_open requires a code
past 255 characters to round-trip; sharing one NumFmt per referenced id
already removes the amplification. Keep the referenced-id sharing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: bound only what a shared formula costs beyond an ordinary cell; count characters for the length warning

Review follow-up for #2197 (@skylartaylor):
- Each dependent of a shared formula now pays only its cost beyond an ordinary formula cell
  (SHARED_FORMULA_FREE_COST, 4 KiB by shared_cost), and the per-workbook budget for that excess
  drops from 2 GiB to 256 MiB. Many ordinary shared formulas still all expand; a long master
  copied into thousands of cells stops at the budget and keeps the cached values.
- The over-long-master warning now uses the parser's own test (bytes, then characters), so an
  unparseable multibyte master no longer gets a false length warning.

Refs #2173

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-06 10:52:53 +01:00
Harrison BoothandClaude Sonnet 5.5 21b64df0fe Paste of a whole-column or whole-row copy no longer aborts the app (#2018)
* Paste: whole-column paste and validation without per-cell blowup

edit.paste of a whole-column or whole-row copy walked every cell of the
copied block (up to the whole sheet) and, for each one, re-cut every
validation rule and pushed a one-cell rule. Over a 2M-cell block that is
quadratic and the fuzz soak aborted on 34-68 GB allocations.

- Paste visits only cells that hold something in the source or the
  destination; blank onto blank changes nothing (and with an operation
  stays blank, as in Excel, instead of becoming 0).
- Validation pastes as ranges: the pasted rectangle is cut once out of
  the destination's rules and each source rule comes along as one rule
  over its translated parts (Skip Blanks or not, as Excel does).
- Whole columns and rows paste whole, so destination content further
  down the columns is replaced, as in Excel.
- A paste that does not fit on the sheet is refused, as Excel does,
  instead of pasting part of the block (whole columns below row 1, whole
  rows right of column A, a whole column transposed, a block at XFD).
- Paste Link, the one paste that writes every cell, is capped at
  1,048,576 cells; tiled validation at 100,000 ranges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Paste: visit destination cells once per paste, add link, transpose and tiling tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Paste: visit link-only cells so a cut keeps their hyperlink

* Paste validation: Skip Blanks keeps the destination rule under a blank source cell with no rule

* Paste validation: Skip Blanks collects clearing cuts only when a rule is under the paste, merges them into runs, and limits only generated areas

* Paste validation: annotate the clearing cut list

* Qualify paste validation context type

* Fix paste-special clippy warnings

* engine: batch validation range cuts

* Fix range type inference

* Annotate validation range index type

* Avoid shadowing test session helper

* Keep validation rows after the final cut

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 22:24:59 +01:00
Harrison BoothandClaude Sonnet 5.5 292b784f02 fix(calc): apply "Set precision as displayed" in calculation (#1366)
* ft-121.7.9: apply Set precision as displayed in calculation (#1343)

With the option on, a formula result (and each element of a spill) is
rounded to its cell's number format before it is stored and before other
formulas read it. Numbers typed, pasted or reformatted while it is on are
rounded at once, and turning it on through formulas.calculationOptions
{precisionAsDisplayed} rounds every number constant, as Excel does.

The rounding is NumberFormat::round_as_displayed in the numfmt crate,
which shares the renderer's digit logic (fixed, scientific, fraction).
General, date/time, text, digitless and conditional formats keep the full
value, as observed in Excel through COM. Nothing changes when the flag is
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ft-121.7.9: round conditional formats by the sign's section under precision as displayed (#1343)

Excel does round formats with a condition when "Set precision as
displayed" is on; the PR kept them at full value. Observed through COM
with synthetic values (and in gridcraft-corpus
excel/precision-displayed/pad-on-conditional-sections.xlsx): conditions
are ignored and the sign picks the section, the first for positives and
the second for negatives. `[<1]0.00;0` shows 2+1/3 as 2 but stores 2.33,
and stores -10/3 as -3.

Two more observed details: with a condition and a single number section,
negatives keep the full value (`[<1]0.00`), unless an `@` section follows
(`[<1]0.00;@` rounds them by the first section); and a completely empty
section passes to the next one (`0.0;;0.000` stores -2.333 as -2.333,
`;0.0` stores 2.333 as 2.3), for formats with or without a condition.

Display is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* engine: recalculate spill anchors when a spill cell is reformatted under precision as displayed

round_edited_as_displayed only looked at stored-cell changes, but a spill
destination lives in Sheet::spill, so reformatting it (or its row or column) left
the anchor out of the changed set and the element unrounded. Add each spill anchor
whose range holds a changed cell or a changed row/column style to the changed set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* numfmt: drop a cast the fraction merge made redundant

* precision as displayed: keep locked constants on protected sheets, round new sheets, fix manual-mode comment

* precision as displayed: name the input sheet in the new-sheet histogram test

* precision as displayed: use six values in the new-sheet histogram test

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:24:49 +01:00
FredApps c402953828 Merge pull request #3425 from ZIDAN44/fix/2874-german-insert-cells
i18n: German context menus' Insert reads "Zellen einfügen", not a second "Einfügen" (#2874)
2026-11-05 15:03:10 +01:00
FredApps 0a58f94517 Merge remote-tracking branch 'origin/main' into pr 2026-11-05 14:49:58 +01:00
FredApps 6ea9f538a7 Merge pull request #2593 from helenkwok/web/host-page
Web: page host mode, so a page can host GridCraft in its own document (#2592)
2026-11-05 13:58:25 +01:00
Pranav 4e43ad8868 UI: RefEdit follow-ups: a dialog keeps the sheet it was opened on, a click on the selected cell picks, quoted sheet names in Solver (#3180)
* UI: a dialog's reference boxes belong to the sheet it was opened on, recorded when it opens rather than at the first pick

* UI: a click on the cell already selected writes it into the reference box being picked into

* Solver: references split into areas only at commas outside quoted sheet names, through one splitter shared with the reference boxes

* Solver: a constraint splits at its relation only outside quoted sheet names, in constraint text and in a model loaded from cells

* Solver: Save Model counts a constraint's areas outside quoted sheet names, and reads a number on the right side as one value whatever its separators

* UI: a Ctrl+click that follows a link is no pick of the cell already selected

The press on the grid tells the reference box being picked into that a
cell was selected only in the branches that select: Shift+click, Ctrl+click
that adds a cell, and a plain click. A Ctrl+click on a link's text follows
the link and selects nothing, so a link back to the selected cell no longer
replaces the box's text.

* UI: while a reference box picks, a second quick click on a cell picks it

A click on the same cell within the double-click interval started editing
the cell and skipped the pick, also when the earlier click came before the
box was clicked into. While a reference box is the one grid selections
fill, the double-click handling is skipped and the click picks.

* Docs: reference boxes take a second quick click as a pick; a followed link is none

* UI: while a reference box picks, a click on a link or checkbox picks its cell

A link was followed (Go To moved the selection into the box, and a web
link's question replaced the dialog) and a checkbox was toggled. While a
reference box is the one grid selections fill, a click only picks the cell
clicked, with or without Ctrl. The test of the Ctrl+click on a link back to
the selected cell becomes part of a test of links on the same sheet, a web
link and a checkbox.
2026-11-05 13:20:51 +01:00
Helen Kwok e06b2a7ead gridcraft-web: speak the host messages through craft-web-host
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); GridCraft keeps the download hook, the open size limit, the workbook-saved rule and the author.
2026-11-05 17:51:00 +08:00
Helen Kwok bf6bee500a web host mode: refuse an oversized host file before copying it, cap the name, and document trust and limits 2026-11-05 17:03:44 +08:00
Helen Kwok d628abb7b0 Web: page host mode, so a page can host GridCraft in its own document (#2592)
data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; Bridge holds a Transport (Parent or Page) and shares the save, dirty and ready logic. Unframed ?host=parent still gets no bridge. Hosting guide documents both modes.
2026-11-05 17:03:44 +08:00
Zinadin ZidanandClaude Opus 5.5 cb05971659 i18n: German context menus' Insert reads "Zellen einfügen", not a second "Einfügen" (#2874)
German showed "Einfügen" for Paste and again for Insert in the cell menu
("Einfügen…") and the row and column header menus ("Einfügen"). The cell
menu's Insert… now reads "Zellen einfügen…", as Excel's German menu does.
The header menus' Insert gets its own key, "Header Menu|Insert", German
"Zellen einfügen"; the plain "Insert" stays as the Insert dialog's title
and button use it, and every other catalog gets the new key with the text
it shows today.

A test reads the three menus' items from menus.rs and checks that no two
read alike in German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:04:32 +06:00
vangel76andClaude Sonnet 5.5 558312cb53 Merge upstream/main into c1155
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 05:20:58 +01:00
Francisco Herreraandalosslessdev 1fb191571b Charts: draw every doughnut series as a ring (#1827)
* Charts: draw every doughnut series as a ring (#1827)

* Charts: submit doughnut hole size and first slice angle when the drag ends (#1827)

---------

Co-authored-by: alosslessdev <alosslessdev@users.noreply.github.com>
2026-11-05 05:13:35 +01:00
FredAppsandClaude Opus 5.5 ede12f9da6 About: credits tables fit without side-scrolling and their columns resize (#3320)
* About: credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:46 +01:00
FredApps 642980b724 Merge pull request #481 from prxnvt/pr/form-controls
Form controls: buttons, sliders and spin buttons, with Assign Action, Format Control and XLSX round trip
2026-11-05 04:16:46 +01:00
FredApps 73fad54671 Merge origin/main into pr/form-controls 2026-11-05 04:05:43 +01:00
vangel76andClaude Sonnet 5.5 23c909e477 chart perf tests: import LegendPos (legend module now imports it itself)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:53:24 +01:00
vangel76andClaude Sonnet 5.5 0955378d8e Merge upstream/main into perf/chart-measure-allocs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:50:34 +01:00
vangel76andClaude Sonnet 5.5 756d5981b1 Merge upstream/main into fix/binom-poisson-accuracy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:41:42 +01:00
vangel76andClaude Sonnet 5.5 7e828a982c Tabs: Ctrl-click on the sheet arrows jumps to the first or last visible sheet (#3350)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:02:21 +01:00
vangel76andClaude Sonnet 5.5 51e242e1fa Manage Rules test: check the dialog rect and which rule was deleted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:52:15 +01:00
vangel76andClaude Sonnet 5.5 8c1c7cfd27 Manage Rules: scroll the rule list and lay out only the visible rules (#3321)
The dialog laid out one row per rule with no height limit and ran
home.manageRules (copying and serialising every rule, and journaling a
step) on every frame. A sheet with thousands of rules made the window
as tall as the whole list and the application window go blank.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:47:47 +01:00
skylar taylor-barrick 919a6b58fe Merge pull request #2203 from Higgs32584/fix/2181-link-open-confirmation
link: require confirmation for programmatic link.open (#2181)
2026-11-05 00:30:08 +01:00
skylar taylor-barrick e8bdd0efd6 Merge pull request #2527 from RedBearAK/xml-node-cap
XLSX: the XML element cap counts only elements kept in the tree
2026-11-05 00:29:00 +01:00
Dylan M. Taylor 3162ac09f3 Pass the Linux signing key to the release workflow (#3380) 2026-11-04 17:45:51 -05:00
prxnvt b9595ccdec Merge origin/main into pr/form-controls
Conflicts:
- crates/xlsx/src/read.rs: main's names.unique() kept with this branch's
  (sheet, read_controls) result from read_sheet.
- crates/ui-egui/tests/it/keytips_generated.rs: regenerated with
  cargo xtask keytips from the merged keytips.json (413 sequences).
- docs/file-format-parity.md, docs/ui-parity.md: header and revision
  history keep main's entries and this branch's form-controls entries.
2026-11-04 13:01:54 -05:00
Dylan M. Taylor 632e4a1bc5 formula, calc: LET/LAMBDA names looked up through a map; ragged array literals refused before reserving (#3312) 2026-11-03 22:57:52 -05:00
Dylan M. Taylor e5f6fa8197 ui-egui: reference colours through a map; cap fill-character repeats per cell (#3311) 2026-11-03 22:57:23 -05:00
Dylan M. Taylor f77ed98cdb chart: a series without values takes no category-span entries (#3300) 2026-11-03 22:56:56 -05:00
Dylan M. Taylor 22c5dfe0df engine: protected sheets gate filter reapply, outline, line sizes and floating pictures; pivot destination resolved before the structure check; user-limited edit ranges stay locked (#3296) 2026-11-03 22:56:06 -05:00
Dylan M. Taylor cdd3a59dbc Treemap rows keep running aggregates; General-fit width is clamped (#3290) 2026-11-03 22:55:35 -05:00
Dylan M. Taylor bebe7de877 engine: cap header and footer sections at 64 KiB when fields expand (#3284)
The 10,000-character header spec was bounded, but each &A, &F or &Z
added the whole sheet name or path again, and a JSON workbook's sheet
name has no length limit. A field that would take a section past
64 KiB is now left out.
2026-11-03 22:55:07 -05:00
Dylan M. Taylor 4421bc6790 engine: weekday fill series moves by whole weeks (#3272)
edit.fillSeries with dateUnit "weekday" stepped one calendar day at a
time, so a large step cost work proportional to its size for every
cell. It now uses add_weekdays, which moves whole weeks arithmetically.
2026-11-03 22:54:17 -05:00
Dylan M. Taylor 017c80572c functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit (#3263)
* functions: match TEXTSPLIT/TEXTBEFORE/TEXTAFTER delimiters through an index, within a work limit

The delimiter matcher compared every delimiter, duplicates included, at
every position of the text. Delimiters are now deduplicated and grouped
by the character a match is anchored on, and a search that would
compare more than 100 million characters returns #VALUE!.

* functions: pass float literals in the delimiter test
2026-11-03 22:53:47 -05:00
Dylan M. Taylor a31178f519 functions: NUMBERVALUE strips trailing percent signs in one pass (#3262)
Each trailing % copied the rest of the text again, so a long run of
them made one call quadratic. The signs are now counted and cut off in
place.
2026-11-03 22:53:18 -05:00
Dylan M. Taylor 184c0ec84d ods: charge a cell's text when the cell is read, not when its row ends (#3257)
The importer kept every cell of a row and only charged their text to
the 64 MiB allowance when the row finished, so one row of long cells
could hold up to 16,384 MiB before being refused. Text is now charged
as each cell finishes; the row charges only its repeats.
2026-11-03 22:52:28 -05:00
Dylan M. Taylor 065f05dbec Security: MODE counts through an index, FREQUENCY bins by binary search (#3198)
* functions: MODE counts through an index, FREQUENCY bins by binary search

MODE/MODE.SNGL/MODE.MULT searched the list of distinct numbers seen so far
for every input, and FREQUENCY scanned the sorted bins for every data value,
so both were quadratic in computed arrays. MODE now keys counts by value
(-0 folded into 0, NaN never merged) and keeps first-occurrence order;
FREQUENCY finds the first bin at or above each value with partition_point
over the order it already sorts. Results are unchanged.

* address review: FREQUENCY keeps NaN data in the overflow bin and NaN bins empty
2026-11-03 22:51:58 -05:00
Dylan M. Taylor db05400e67 calc: list each dependent once when a change spreads (#3190)
* calc: list each dependent once when a change spreads

* calc: check the dependency closure against a walk of every cell

Random graphs of single-cell, narrow and wide range reads (some removed or unsettled), random seeds and changed areas: the closure with shared taken records and area lookups equals the one listing every dependent of every changed cell.
2026-11-03 22:51:29 -05:00
Dylan M. Taylor c0ea297615 calc: cap the text one & over arrays may build in all (#3189)
* calc: cap the text one & over arrays may build in all

* address review: concatenation over arrays stops at the byte budget without reserving the whole broadcast
2026-11-03 22:51:11 -05:00
Dylan M. Taylor 74111e5a93 check: charge relationship parts to the package's decompression budget (#3185)
* check: charge relationship parts to the package's decompression budget

* address review: charge bytes read before a package part decompression error
2026-11-03 22:50:40 -05:00
vangel76andClaude Sonnet 5.5 b3a709f314 contributors: set names for vangel76
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:12:09 +01:00
vangel76andClaude Sonnet 5.5 86c5c8dd81 Math: count \newline as a row break in the matrix cell cap (#3327)
RaTeX reads \newline as \\, but the source scan only counted \\ and \cr, so
a matrix built with \newline skipped the 2,500 cell cap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:31:04 +01:00
Dylan M. Taylor 014f030a15 engine: PivotTable filters test each record against a set of selected labels (#3279)
Every source record scanned the filter's whole selection, so a large
selection multiplied by many records was slow even when nothing passed.
Each filter's labels are lowercased into a set once per computation.
2026-11-03 18:10:48 -05:00
vangel76andClaude Sonnet 5.5 f077b9ef36 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:05:58 +01:00
vangel76andClaude Sonnet 5.5 40e75c7912 binom/poisson reference test: fail on errors, NaN and non-zero results for zero references (review point)
CodeRabbit pointed out that an error or NaN result passed the accuracy test and
that a zero reference allowed any finite value (the bound was infinite). Errors
now count as infinite error and a zero reference allows 1e-300.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:05:58 +01:00
vangel76andClaude Sonnet 5.5 d8ba397bb7 math: refuse an alignedat count hidden by a comment or spaces (review point: cap bypass)
CodeRabbit found that \begin{alignedat}%<newline>{99999999} and {9 9} made the
count parser return None, which skipped the 50-pair cap. The lookahead now drops
comments and spaces as RaTeX's lexer does, and a matched alignat name with a
count that is not a plain digit string is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:04:32 +01:00
vangel76andClaude Sonnet 5.5 7e8b94fc39 BINOM.DIST: keep tiny masses and huge n out of the product form
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:54:53 +01:00
vangel76andClaude Sonnet 5.5 22c06b7a4a BINOM.DIST, POISSON.DIST: Loader mass, recurrence sums, exact prefactors (#3324)
The mass came from ln_gamma differences (relative error 1.1e-13 at n = 100,
1.5e-11 at n = 10,000) and the cumulative forms summed up to 10,000 of them.
The mass now comes from Loader's saddle-point formula, the cumulative sum
walks away from the mode by the ratio of neighbouring terms and stops when
terms stop mattering, and the incomplete beta / gamma branches take the same
mass as their prefactor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:49:52 +01:00
Dylan M. Taylor a305229287 PDF export: wrap text with running widths (#3208)
wrap_lines split an over-long word by re-measuring and copying the
rest of the word for every piece it cut, and only checked its line
limit once the word was done, so one long word in a narrow text box
cost word length x pieces. Each new word also re-measured the whole
line built so far.

Line and piece widths are now running sums of glyph widths (the same
sum text_width makes), so every character is measured once, and the
line limit is checked while a word is being split. The lines produced
are unchanged; a test compares them with the old wrapper.
2026-11-03 16:11:47 -05:00
vangel76andClaude Sonnet 5.5 1ff10df557 fix(autosum): stop the guessed range at a blank cell (#3291)
The single-cell AutoSum guess stepped over one blank cell when a number sat
above it, so a second block of numbers below a gap was summed together with
the first block. It now stops at the first non-number, as Excel does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 20:56:00 +01:00
vangel76andClaude Sonnet 5.5 472132b158 math: refuse macro definitions and cap matrix cells
RaTeX counts macro expansions (1,000) but not how large they grow, so a
definition whose argument doubles on every call keeps the expander busy
without bound. User macros have no use in an equation, so check_caps now
refuses the definers (def, gdef, edef, xdef, let, futurelet, global, long,
newcommand, renewcommand, providecommand) before RaTeX sees the source. The
scan reads control words, verbatim text and comments the way RaTeX's lexer
does.

A matrix is laid out and written as rows times the widest row, so one long row
in a tall matrix multiplied the output. check_caps now counts the padded cells
of every environment from the source and refuses more than 2,500 in total;
to_omml checks the parsed grid as well.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 19:50:27 +01:00
Higgs32584andClaude Opus 5.5 e12b462b90 Port the link.open confirmation check into the merged insert.rs (#2181)
Upstream folded cmd/insert/*.rs back into insert.rs, so the merge left
the policy check in a file that is no longer compiled. Restore that file
to upstream and apply the check in the live open_link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:45:50 -05:00
Michael Higgins 94eed2bd83 Merge branch 'storytold:main' into fix/2170-shape-font-attr-escape 2026-11-03 10:10:26 -05:00
Higgs32584andClaude Opus 5.5 fe062f57a6 Refuse synthetic control-channel input as OK in Open Hyperlink (#2181)
A ui.key Enter or ui.click on OK reached egui as ordinary input and
could confirm the openLink prompt. A pass that carries synthetic input
now keeps the prompt open and opens nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:48:20 -05:00
Higgs32584 94dd42ba31 Merge upstream main into fix/2181-link-open-confirmation 2026-11-03 09:45:59 -05:00
prxnvt 89e141ca3e Merge origin/main (7bb397a3, the squash of #494) into w/form-controls 2026-11-02 16:03:05 -05:00
prxnvt 99b5063489 XLSX: a kept VML drawing leaves out a modelled control by the number the reader gave it, its id before its o:spid, so the control is written once 2026-11-02 15:19:13 -05:00
prxnvt 02df071b4c XLSX: a kept VML drawing drops a shape as a note only when its client data's ObjectType is Note, so a control whose caption or other attribute reads like one is kept 2026-11-02 15:18:54 -05:00
prxnvt 5725f7b44e XLSX: a kept VML drawing drops a note whatever the case or quotes of its ObjectType, so a comment gets one note 2026-11-02 14:44:00 -05:00
vangel76andClaude Sonnet 5.5 bcad4a75cf Merge calc: register a Python result's spill in the spill index (#2694) into perf/calc-spill-unblock-2684
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:32:11 +01:00
vangel76andClaude Sonnet 5.5 e85b24df57 calc: register a Python result's spill in the spill index
python_result wrote into spills but not into spill_index, so indexed readers
(cell_value, range_values, visit_range) missed a Python array laid out in the
same pass. Insert on spill, remove when blocked, as the formula path does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:31:52 +01:00
prxnvt 00d79eb98a Form controls: control.format replies with the control as committed, so a new link's value shows 2026-11-02 14:21:29 -05:00
vangel76andClaude Sonnet 5.5 4fe0816ca0 Merge upstream/main into fix/chart-work-budgets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:49:07 +01:00
prxnvt 675d4b9a3c XLSX: form controls that only the VML drawing lists are kept on save when no <controls> element is left, as when the last listed control is modelled 2026-11-02 13:44:33 -05:00
vangel76andClaude Sonnet 5.5 00f565bc11 Factor out types clippy flags as too complex
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:44:16 +01:00
vangel76andClaude Sonnet 5.5 4b98b11996 Merge upstream/main into perf/calc-range-memo-2681
A Python cell is not a constant for the range_sum block memo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:41:44 +01:00
prxnvt 57ad523b83 Form controls: a button's question is answered only while the workbook, sheet, button, action, macro and selection it asked about are unchanged 2026-11-02 13:39:50 -05:00
vangel76andClaude Sonnet 5.5 21504859c8 Merge upstream/main into perf/calc-expr-arc-2685
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:36:41 +01:00
vangel76andClaude Sonnet 5.5 a9db1da90d Merge upstream/main into perf/calc-spill-index-2683
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:16 +01:00
vangel76andClaude Sonnet 5.5 96672e8ad5 Drop helpers the merged range reads no longer use
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:33:53 +01:00
vangel76andClaude Sonnet 5.5 628da5117a Merge upstream/main into perf/calc-spill-unblock-2684
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:28:52 +01:00
prxnvt 7202d1cf7c XLSX: a modelled control joins the Choice branch's <controls> element of a kept AlternateContent
The pass-through writer put GridCraft's control entries before the last <controls> close tag of
the kept fragment, so a kept mc:AlternateContent with separate <controls> elements in its Choice
and Fallback got them in the Fallback, which the reader (and an application that knows x14)
doesn't read: on reopen the control came back from its VML shape alone, without its name and
its properties part. The entries now go into the <controls> element the reader reads: the
Choice's when the Choice holds one, else the first one (a plain fragment, or a Fallback alone);
a Fallback's element is left as the file had it. Tags are matched by local name, with any
namespace prefix.

Test: a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content (the saved
Choice element holds the kept list box and the modelled slider, the Fallback only the list box;
the file reads back with the slider's name, link and value from its worksheet entry and
properties part, and the list box kept).
2026-11-02 12:33:10 -05:00
prxnvt c1249e3ad1 XLSX: a modelled control joins a kept <controls> element whatever its namespace prefix
The pass-through writer found the kept element's end by the literal `</controls>`, so a producer
that prefixes the main namespace (`</x:controls>`) got a second <controls> element for the
modelled controls, which the worksheet schema does not allow. The close tag is now found by its
local name with any prefix. No other literal tag search is left in rebase.rs; the ones in read.rs
(VML `<o:idmap>`, `<v:shapetype>`, `ObjectType="Note"`) rely on the fixed VML prefixes and are
main's code.

Test: a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix (a kept
`<x:controls>` with a list box and a modelled slider save as one controls element holding both,
and the file reads back with the slider modelled and the list box kept).
2026-11-02 12:06:25 -05:00
prxnvt f18ec0407f Form controls: a fixed input range is refused for a macro that takes no inputs
A button could be given a fixed input range for any macro name; pressing it then passed the range
to macro.run as `inputs`, which check_params refuses for a macro without inputs ("it takes no
inputs"), so such a button failed on every press. Now insert.button and control.assign refuse a
fixed input when the named macro exists and declares no inputs, naming the macro; a macro the
workbook does not have yet is not checked, and the assignment is kept as given. On open, a stored
fixed input for a macro without inputs becomes the selection: the file itself assigns the macro
on the selection, and the stored range would fail every press.

Tests: a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned (insert and assign are
refused with the reason in any letter case; a macro with an input and a macro not yet defined
take the range; a stored fixed input comes back as the selection). format_and_assign and
a_kept_button_action_comes_back_only_on_the_same_button give their macro an input.
2026-11-02 12:06:25 -05:00
prxnvt 5483c7bc47 Merge origin/main (9172516a, the Python cells pull requests) into w/form-controls
ROADMAP.md: header combined (form controls first, then main's Python mode and Python cells notes); the form-controls progress-log and revision rows above main's Python rows.
crates/engine/src/io.rs: open_plain's doc comment names both (the button actions kept in hidden names, and the adopted Python cells); after the macros it loads the button actions, then adopts the Python cells and enforces their limits.
crates/ui-egui/src/theme.rs: this branch's sheet control colours and main's PY tag colour.
docs/file-format-parity.md: header combined (form controls first, then main's Python cells note); the form-controls revision row above main's Python row.
docs/ui-parity.md: header combined (form controls first, then main's Python mode note; ribbon catalog count 281 / 296); the form-controls revision row above main's Python row.
2026-11-02 11:39:35 -05:00
prxnvt 2fa94f111f Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls
Both sides added a kind of floating object (equation, form control); both are kept everywhere.
ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows.
crates/engine/src/cmd.rs: both spec lists (controls and equation).
crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds.
crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly.
crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds.
crates/engine/src/tests.rs: both test modules.
crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both.
crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both.
crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes.
crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment.
crates/ui-egui/src/chartview.rs: both doc comments combined.
crates/ui-egui/src/objects.rs: both OpenAction variants and arms.
crates/ui-egui/src/ribbon/insert.rs: the import list is the union.
docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence.
docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows.
docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296).
docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's.
docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
2026-11-02 10:47:43 -05:00
nihilist-narwhalandClaude Opus 5.5 e7cda6337b MCP: the stdio server keeps at most 16 MB of a message (#2164)
`serve` read a whole line before checking it against `MAX_LINE`, so a client sending a
newline-free payload grew the buffer without limit. The line is now read through `take`, as the
control server does; the rest of an over-long line is read and dropped, the client gets the
"message too large" error, and the next message is served (stdio has one client, so the stream
stays open rather than closing as the control server's connections do).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:49:34 -06:00
prxnvt 5cbc168441 XLSX: a kept control beside a modelled one in the same AlternateContent stays with pass-through
without_controls dropped a whole child of a kept <controls> element as soon as one control in it
was modelled, so an mc:AlternateContent holding a scroll bar (modelled) and a list box (left to
pass-through) lost the list box's worksheet entry on save, while its VML shape and properties
part stayed. The controls are now taken out one by one, in the Choice and the Fallback branches
alike, by the shapeId the control reader reads (an attribute, in either quote style); the wrapper
stays while a control remains and goes only when it is empty, and a child with none of ours is
kept unchanged. strip_shapes already works per shape, so the kept control's shape stays, and its
properties part follows its relationship as before.

Test: an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept (after open
and save the scroll bar is written once, the list box is still in <controls> in both branches
with its VML shape and its properties part, and the file reads back with one modelled control and
the kept one).
2026-11-02 05:39:45 -05:00
prxnvt 6b93106575 XLSX: a kept control is matched to the modelled ones as the reader reads it, in either quote style
without_controls found the controls to drop from a kept <controls> fragment by splitting its
text on `shapeId="`. The control reader takes the attribute through the XML parser, which also
accepts `shapeId='1025'`, so a producer's single-quoted control was modelled, its VML shape taken
out of the kept drawing, and the control itself still kept: a save wrote it twice, the kept copy
pointing at a shape that no longer existed. The kept child is now parsed and its controls found
with the reader's own helpers (controls_of_child, shape_number), so what the reader modelled is
what is dropped. The shape type check of the VML merge (`<v:shapetype id>`) accepts either
quote style too, so a kept drawing that defines our shape type with single quotes is not given
a second definition.

Test: a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too (the control is read once,
nothing of it is kept, the saved sheet holds one control and no single-quoted copy, the VML one
shape, and the file reads back with one control).
2026-11-02 05:14:17 -05:00
prxnvt 112a9c78b9 Form controls: a linked cell's formula survives a write of the value it already shows
write_link skipped the write only when the linked cell held a constant equal to the value; a cell
with a formula was always written, so Format Control's OK (which sends the value back every
time) replaced the formula with a constant even when the formula showed that value, and on a
protected sheet with the linked cell locked a rename was refused. The value the cell shows is now
compared whether or not it holds a formula: a write that changes nothing is skipped, and a real
change still replaces the formula with the constant, as in Excel.

Test: a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows (insert and Format
Control with the value the formula shows keep the formula, also on a protected sheet with the
cell locked; a changed value writes the constant).
2026-11-02 05:13:55 -05:00
prxnvt d09694ff61 Form controls: a slicer tile under a control is not toggled by a click meant for the control
A click on a form control that form_controls::interact leaves to the grid (Ctrl or Cmd+click,
which selects the control; a click on a selected control, which moves it) fell through to the
slicer tile branch, which looks only at slicers, so a control placed over a slicer's value tile
had the tile toggled and the slicer selected before the generic object handling could select or
drag the control. The slicer tile branch now runs only when the press lands on a slicer by the
same test the generic path uses (the selected object's handle first, then the topmost object), so
the z-order decides once; a control over a tile, and a selected object's handle over one, keep
the generic path. Charts, pictures and shapes lie beneath slicers in that order, so they were not
affected.

Test (kittest): a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click (a button
over the first tile; Cmd+click selects the button and the slicer's filter is unchanged; a click
on the selected button still toggles nothing; an uncovered tile still toggles).
2026-11-02 04:46:13 -05:00
prxnvt 76c70c2b89 Merge origin/main (3379f12a, the squash of upstream pull request 501) into w/form-controls
ROADMAP.md: header combined (this branch's form-controls note first, then main's Solver Sensitivity report note; Last updated 2026-11-02); the form-controls progress-log and revision rows stay above main's Solver Sensitivity rows.
docs/target-app-parity.md: header combined (this branch's form-controls note first, then main's Solver Sensitivity report note); the form-controls revision row stays above main's Solver Sensitivity row.
2026-11-02 03:48:06 -05:00
prxnvt ccc2fb6076 Merge origin/main (f6a0556b, the squash of upstream pull request 470) into w/form-controls
origin/main's tree is identical to 05851de4, the head of pull request 470 that a4faa909 already merged, so every conflict is the same change on both sides and this branch's side is kept:
ROADMAP.md: this branch's side (main's #470 header text and rows are already in it, with the form-controls header text and rows).
crates/engine/src/cmd/edit/paste_special.rs: this branch's side (identical #470 change, plus the control kind).
crates/engine/src/cmd/extra.rs: this branch's side (identical #470 change, plus the control commands).
crates/engine/src/cmd/extra/objects.rs: this branch's side (identical #470 change, plus the control kind).
crates/engine/src/cmd/insert.rs: this branch's side (identical #470 change, plus the control kind in object.move).
crates/engine/src/cmd/sheet/tabs.rs: this branch's side (identical #470 change, plus a copied sheet's controls).
crates/engine/src/tests/objects.rs: this branch's side (both added the file; this branch's has #470's tests and the copied-sheet controls test).
crates/model/src/features.rs: this branch's side (identical #470 change, plus the form-control types).
crates/model/src/sheet.rs: this branch's side (identical #470 change, plus the controls field and its accessors).
crates/model/src/workbook.rs: this branch's side (identical #470 change, plus the controls' ids).
crates/ui-egui/src/chartview.rs: this branch's side (identical #470 change, plus hit-testing controls).
crates/ui-egui/src/grid/cursor.rs: this branch's side (identical #470 change, plus the control cursor).
crates/ui-egui/src/grid/interact.rs: this branch's side (identical #470 change, plus the form-control interaction).
crates/ui-egui/src/objects.rs: this branch's side (both added the file; this branch's has #470's code and the control kind).
docs/control-protocol.md: this branch's side (main's size-cap sentences are already in it, with the control kind paragraph).
docs/target-app-parity.md: this branch's side (main's #470 header text and row are already in it, with the form-controls header text and row).
docs/ui-parity.md: this branch's side (main's #470 header text and row are already in it, with the form-controls header text and row).
2026-11-02 03:37:59 -05:00
prxnvt d1f69878f6 Form controls: Space on a checkbox cell toggles it once while a slider or spin button is focused
With a slider or spin button focused, the Space key reached the grid and toggled a checkbox cell,
and the space it types then started a cell edit over the toggle (Escape cancelled the edit but
not the toggle). Now the typed space is dropped while a control is focused and the active cell
is a checkbox, as it is for a focused button: Space on a cell does what it does with no control
focused and nothing more, a checkbox cell toggles once with no edit, any other cell starts an
edit with a space. The path with no control focused is unchanged.

Test (kittest): space_on_a_checkbox_cell_toggles_it_once_while_a_slider_is_focused (toggled once,
no editor, the slider's value and focus unchanged; a second Space toggles back).
docs/ui-parity.md says so.
2026-11-02 03:06:40 -05:00
prxnvt 8495cc99e1 Form controls: Space on a focused slider or spin button starts a cell edit
The space a Space press types was dropped while any control was focused, because Space presses
a focused button. A slider or spin button doesn't take Space, so with one focused Space neither
worked the control nor started a cell edit, while every other character did. Now the space is
dropped only for a focused button; on a slider or spin button it reaches the grid like any other
character and starts a cell edit. The control keeps the keyboard, as the control last worked:
Space is a key it doesn't take, exactly like a letter, and letting go on Space alone would make
Space differ from every other printable key.

Test (kittest): space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused (a cell edit
starts with a space, the control's value is unchanged, Escape cancels, the arrows still step it).
docs/ui-parity.md says so.
2026-11-02 02:41:19 -05:00
prxnvt be0882a148 Form controls: a button gives the keyboard back when the grid takes a key or a click
A click on a button focused it, and the focus stayed while the grid moved the cell cursor with
the arrows, Tab or Delete, or opened a cell's menu with a right-click, so a later Enter or Space
pressed the button again without a new click, and Space could not start a cell edit. Now a key
press the focused button doesn't take (any key but Space, Enter and Escape, or one with Ctrl,
Cmd or Alt) gives the keyboard back to the grid, and any press that isn't a left click on the
grid, a right-click on a cell included, moves the focus off a control. A slider or spin button
keeps the keyboard as the control last worked.

Tests (kittest): a_button_gives_the_keyboard_back_when_the_grid_takes_a_key (arrow, then Enter
moves the cursor and does not press; arrow, then Space starts a cell edit; Cmd+arrow takes the
keyboard too) and a_right_click_on_a_cell_takes_the_keyboard_from_a_button. docs/ui-parity.md
says so.
2026-11-02 02:22:18 -05:00
prxnvt 36a44a35cb Form controls: a button never opens a web page or a dialog
A button's command ran through the macro policy and the file sandbox, but not through the macro
runner's request filter, so a file-borne button assigned `link.open {url}` opened the page once
the generic trust question was answered. Now `link.open` is refused for buttons up front, when it
is assigned and when a file brings it in, with the wording of the other denied commands; and
after a press runs its command, the UI requests it queued are checked as the macro runner checks
a step's: a dialog or a web page makes the press fail with the reason and rolls the group back,
a cell edit is dropped, and messages for the person stay queued.

Tests: link.open joins a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in
(assign, insert and a file-borne press refuse it; no OpenUrl request is left), and
a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs (insert.link with no
parameters is refused with the dialog wording and its request dropped; the filter refuses a page,
drops a cell edit and keeps messages). docs/macros.md says what a button's command may not do.
2026-11-02 02:20:48 -05:00
prxnvt 975d5407a1 Form controls: a kept button action must carry the button's name and anchor cell
An entry of the hidden name without `name` or `at`, or with something other than text there,
skipped those checks (is_none_or) and could pin its action onto whatever button sat at the stored
index. The writer always stores both keys, so such an entry is now dropped like an unparsable one
(is_some_and); the doc comment says so.

Test: a_kept_action_without_the_buttons_name_or_anchor_is_dropped (an entry without `name`,
without `at`, with a number for `name` and with null for `at` gives the button no action and the
hidden name is removed; the writer's own entry still comes back).
2026-11-02 02:13:19 -05:00
prxnvt a4faa9091e Merge w/objects (05851de4) into w/form-controls
crates/engine/src/cmd/insert.rs: the object.move params line keeps both the `control` kind (this branch) and the size-cap note (w/objects).
crates/engine/src/tests/objects.rs: both sides' new tests kept, the copied-sheet controls test first, then the two oversized-object tests.
crates/ui-egui/tests/it/chart_data_table.rs: w/objects' own fix (21a27a76, `selected_object = Some(ObjRef::new(ObjKind::Chart, id))`) replaces the equivalent select_object_id line of dcefd523.
docs/control-protocol.md: this branch's `control` kind paragraph, followed by w/objects' sentences on the object.move and chart.set size cap.
crates/model/src/sheet.rs: git merged both sides' `data_table: None` lines into the push_object Chart literal (dcefd523 and 21a27a76 made the same change at different places); the w/objects line is kept, the dcefd523 one dropped.
2026-11-02 02:09:25 -05:00
prxnvt 05851de46c Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension
The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).

Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".
2026-11-02 02:03:47 -05:00
prxnvt af28f01bfd Objects: dragging an object's body sends its position only, not its size
`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.

Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.
2026-11-02 01:58:23 -05:00
prxnvt 381a121339 Objects: object.move and chart.set keep a size a file made larger than 10,000
`object.move` clamped `width` and `height` to 10,000 points, the most it
gives a new size, while the XLSX reader accepts anchors far past that.
A resize of a picture a file made 12,000 wide passed 11,990 through the
clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends
the unchanged dimension back with the edited one, clamped the same way.

Each dimension is now capped at 10,000 or the object's current size,
whichever is larger (`size_cap`): such an object keeps its size when it
is moved, shrinks by what a resize asks and never grows further, and a
hostile size is still capped. The params doc of `object.move` and the
control protocol notes say so.

Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize
makes a picture 12,000 by 300, writes and reads it back as a file, then
moves it without a size (12,000 stays), asks 12,010 (stays 12,000),
11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000).
chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the
same through `chart.set`. Both fail without the fix (width 10,000).
2026-11-02 01:58:23 -05:00
prxnvt 21a27a76be Objects: main's chart data table compiles with the object selection and the model's object test
Two leftovers of the merge of origin/main (25881a8e), neither a textual
conflict: `Chart` gained a `data_table` field, which the `push_object`
helper of the model's object test did not set (it is `None`, as in
`insert.chart`); and chart_data_table.rs set the `selected_chart` field
this branch replaced by `selected_object`. The test selects its chart as
a floating object, as the branch's other tests do.
2026-11-02 01:57:49 -05:00
prxnvt 099349d91d Form controls: a macro assignment removed in another application stays removed on reopen
load_action_names gave every kept action back to a button the file assigned no macro. A button
that ran a macro with a fixed input range or an output cell, whose macro another application
removed while keeping the button's name and place, so came back with the assignment. Now a kept
macro action comes back only onto a button the file still assigns that macro, and a kept command
only onto a button the file assigns no macro, as the function's comment already said.

Tests: a_macro_assignment_removed_in_another_application_stays_removed (the saved file with the
controlPr macro attribute and the VML FmlaMacro removed reopens with no action and no hidden name;
unedited, with the whole action), a_macro_assigned_in_another_application_replaces_a_stored_command
(the file's macro wins over a kept command), and the cleared case in
a_kept_button_action_comes_back_only_on_the_same_button. macros.md and file-format-parity.md
describe the hidden name and the reopen rule.
2026-11-02 01:47:15 -05:00
prxnvt dcefd523eb Merge w/objects (082e3794, main 25881a8e) into w/form-controls
ROADMAP.md: both header changes (form controls first, then main's chart data tables), the form-controls progress-log and revision rows on top of main's rows.
docs/file-format-parity.md: both header changes (form controls first, then main's page order and chart data tables), the form-controls revision row on top of main's rows.
docs/macros.md: both header changes (form buttons first, then main's step-editor close rule).
docs/target-app-parity.md: both header changes (form controls and catalog 280 / 296 first, then main's ToolPak, page order and data tables), the form-controls revision row on top of main's rows.
docs/ui-parity.md: both header changes (form controls first, then main's Trace Error and page order; catalog count 280 / 296), the form-controls revision row on top of main's rows.
crates/model/src/sheet.rs: the push_object test helper (w/objects, b874f586) gets main's new Chart field, data_table: None, so the model crate's tests compile.
crates/ui-egui/tests/it/chart_data_table.rs: main's new test selects its chart through select_object_id, the one selection of w/objects, instead of the selected_chart field that w/objects replaced.
2026-11-02 01:47:07 -05:00
prxnvt 082e379412 Merge origin/main (25881a8e) into w/objects
ROADMAP.md, docs/chart-parity.md, docs/gaps.md, docs/target-app-parity.md, docs/ui-parity.md: the header change notes combine this branch's note with main's, dated today; main's revision and progress rows stay and this branch's rows are re-dated on top. Code merged without conflict.
2026-11-02 01:30:43 -05:00
prxnvt 834fa94f85 Objects: tests that a copied sheet's form controls take free ids and that a control's id stays in use
A sheet copy renumbers its form controls from the same free ids as its charts, pictures, shapes
and slicers, so when a file's slider carries the largest id the copies still get distinct ids;
free_object_ids counts the controls' ids as used, so 3 and u32::MAX held by controls are skipped.
2026-11-01 17:33:29 -05:00
prxnvt 63400c9afd Merge w/objects (a6afa053) into w/form-controls
crates/model/src/workbook.rs: w/objects' next_object_id over free_object_ids replaces this branch's loop; the form controls' ids join the used ids it collects, as the branch's loop had them; the branch's test that a control's id is in the sequence stays beside w/objects' tests.
crates/engine/src/cmd/sheet/tabs.rs (auto-merged): the count of free ids a copied sheet asks for includes its form controls, which the copy renumbers from the same iterator; without it the controls would all take u32::MAX.
2026-11-01 17:32:17 -05:00
prxnvt 18cb02cbd9 XLSX: a control properties part is read once, however many controls name it
Each listed control read and parsed its properties part again, and every read is charged to the
package's 1 GB budget, so a file whose controls all named one part spent the budget on it: the
controls after that were dropped and the parts read later (notes, threaded comments) failed with
TooLarge. The parsed part is now cached by target; a part that fails to read or isn't a
formControlPr is cached as nothing and not read again. Test: 13,000 controls naming one 200 KB
part keep the sheet's 10,000 controls and the note read after them.
2026-11-01 17:30:26 -05:00
prxnvt 4219720e13 UI tests: the button test closes the control's menu before the press it checks
The right-click that selects the button also opens its menu at the pointer, so the plain click
that follows could have gone to the menu instead of the button. The test now closes the menu with
Escape and asserts it is closed before clicking; the dialogs test asserts that choosing a menu item
closes it.
2026-11-01 17:30:26 -05:00
prxnvt a6afa05309 Objects: a copied sheet's objects get free ids even when a file used the largest id
`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a file can carry an object
with that id; every later insert (a chart, picture, shape, table or
slicer) then got `u32::MAX` again, and so did each object of a copied
sheet, whose ids also came from a saturating counter. Lookups and
selection take the first object with an id, and `object.delete` removes
every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead,
and a new one covers a file that used the largest ids: the free ids come
from below, 100,000 of them distinct and in order.

Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a
shape id u32::MAX, inserts two shapes and copies the sheet; every shape id
is distinct across both sheets. It fails without the fix (both new shapes
get 4294967295).
2026-11-01 17:23:58 -05:00
prxnvt d8703162be XLSX: form controls find their properties parts through a relationship index
Each listed control looked its properties part up with a scan of the sheet's relationships, so a
sheet at the 10,000-control cap compared ids a hundred million times. The relationships are
indexed by id once, as the VML shapes already were; the first of a repeated id still wins.
2026-11-01 17:06:51 -05:00
prxnvt 11ffe09439 Form controls: tests for denied commands brought in by a file and for linked cells under protection
A button's command is refused at Insert, at Assign Action and, when a file's hidden name carries
it, at press time (file.save, file.saveAs with a path, file.open, app.commands, macro.run,
automate.runScript, control.press); nothing runs and no file is written. A slider or spin button
whose linked cell is locked on a protected sheet refuses the change through control.setValue,
Format Control and Insert, and keeps its value; a link to another sheet obeys that sheet's
protection; an unlocked linked cell takes the value.
2026-11-01 17:05:25 -05:00
prxnvt 40aa1e8b66 Merge w/objects (df98e9ce, with origin/main f5030432) into w/form-controls
ROADMAP.md: both headers combined (form controls first, then main's entries); the form controls progress-log and revision rows re-dated 2026-11-01 on top of main's rows; the branch's duplicate floating-objects rows dropped in favour of main's.
docs/file-format-parity.md: header combined the same way; the form controls revision row on top of main's rows.
docs/target-app-parity.md: header combined; the form controls revision row (catalog 276 / 292 -> 280 / 296) on top of main's rows; the duplicate chart-resizing row dropped in favour of main's.
docs/ui-parity.md: header combined, its catalog count set to 280 / 296; the form controls revision row on top of main's rows; the duplicate floating-objects row dropped.
crates/ui-egui/tests/it/keytips_generated.rs: regenerated with cargo xtask keytips (370 sequences).
crates/model/src/sheet.rs: the exhaustive push_object test helper and the two-of-every-kind list gained the Control kind, which main's new test module did not know.
2026-11-01 16:59:58 -05:00
prxnvt df98e9cef9 Objects: sorting keeps the size of a "move and size" picture whose lines swap places
A picture anchored to move and size with cells and spanning two rows
(or columns) was re-fitted between its top-left and bottom-right lines
after a sort. When the sort put the bottom line above the top one, the
fitted extent was negative and the picture collapsed to a zero height
or width. It now keeps its old size on that axis and travels with its
top-left line, like a "move only" picture.

Tests: for a sort by rows and one by columns, "move and size" and
"move only" pictures inside one line travel with it (the first
re-fitted to the same line, the second at its own size), an absolute
picture stays while the cells move under it, a two-line picture whose
lines swap keeps its size, and undo restores every anchor.
2026-11-01 16:38:23 -05:00
prxnvt b874f58667 Objects: a test pins that both anchor accessors visit the same objects in the same order
Sorting and row or column edits read every anchor through
`object_anchors`, compute the new ones and write them back through
`object_anchors_mut` in step. Nothing checked that the two accessors
visit the same objects in the same order; a kind of floating object
reaching only one of them would have paired objects with other objects'
anchors silently.

Test: two objects of every kind are inserted in a mixed order, each
anchor is marked with its position through the mutable accessor, the
marks are read back through the shared one in the same sequence, the
count matches the per-kind collections, and the mark is found on the
object `object_anchor` names by kind and id. The test's constructor
matches on `ObjKind` exhaustively, so a new kind does not compile until
the test covers it.
2026-11-01 16:38:23 -05:00
prxnvt dd9a01e3b2 Merge origin/main (f5030432) into pr/objects
ROADMAP.md: main's header and rows kept; this branch's progress-log and revision rows re-dated 2026-11-01 on top; the Change note names both.
docs/gaps.md: main's §13 Solver rows kept; this branch's §4 row re-dated 2026-11-01 on top; the Change note names both.
docs/target-app-parity.md: main's Solver and Insert Function rows kept; this branch's chart-handles row re-dated 2026-11-01 on top; the Change note names both.
docs/ui-parity.md: main's Function Library and Esc rows kept; this branch's floating-objects row re-dated 2026-11-01 on top; the Change note names both.
2026-11-01 16:34:14 -05:00
prxnvt 2b787b1912 Docs: ui.object.select says what it replies when it clears the selection 2026-10-31 22:23:56 -04:00
prxnvt 10de76e6c7 Docs: macro.check's signature in macros.md lists output
The Running section says macro.check takes `output`, but the
macro.check signature further down left it out. It now lists
`output?`, and says the reported output anchor is `output` when given.
2026-10-31 21:22:55 -04:00
prxnvt dd97b29dec XLSX: form controls are read from the drawing <legacyDrawing r:id> names
The reader took the sheet's first VML drawing relationship. A sheet
with pictures in its header or footer has a second VML drawing
(<legacyDrawingHF>), and when that relationship came first the control
shapes were not found: VML-only controls were skipped, and listed
controls lost their caption and macro. The drawing is now the one
<legacyDrawing r:id> names, as pass-through already does.
2026-10-31 16:55:49 -04:00
prxnvt 85c22dd27b XLSX: control properties parts use the ctrlProp relationship type Excel uses
The sheet related each control properties part with
http://schemas.microsoft.com/office/2006/relationships/ctrlProp. MS-XLSX
defines the relationship in the officeDocument namespace,
http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp,
which Excel writes and looks the part up by. GridCraft's own reader
matched either (it goes by the last segment and the r:id), so its round
trips passed; Excel would not have found the parts.
2026-10-31 16:38:18 -04:00
prxnvt e1fbee8ebc Form controls: holding Enter or Space presses a focused button once
The focused control took every pressed key event, the system's key
repeats included, so holding Enter or Space on a focused button ran its
macro or command again for each repeat. A repeat now still belongs to
the button (it doesn't reach the grid) but doesn't press it; arrows,
Page Up and Page Down held on a slider or spin button keep stepping.
2026-10-31 16:37:49 -04:00
prxnvt b8f04d7af9 Macros: the check of each step counts the form controls a step deletes
The footprint counts a deleted form control as a conflict, but the
check made as each step runs counted only charts, pictures and shapes.
After a footprint that stopped early (at Solver, or an approved file
step), a later step could delete a control with no question. It now
counts controls too, so such a step stops the run under fail and asks
under ask.
2026-10-31 16:36:53 -04:00
prxnvt 14dee9155d Form controls: the object commands leave a locked control on a protected sheet alone
control.format and control.assign refused to change a locked control on
a protected sheet, but object.move, object.delete,
object.setAnchorMode, arrange.bringForward, arrange.sendBackward and
arrange.align changed it all the same. They now refuse it with the
same message; an unlocked control still moves, and other kinds of
objects are unchanged.
2026-10-31 16:35:36 -04:00
prxnvt 0fb3ea7a68 Docs: revision history for form controls
file-format-parity.md, target-app-parity.md and ui-parity.md gave form
controls a new timestamp line without a matching revision-history row;
each now has one. The file-format-parity.md timestamp line keeps the
earlier change it replaced, as the other docs do.
2026-10-31 16:12:07 -04:00
prxnvt cc68d71ac2 XLSX: form controls find their VML shapes through an index
Each control listed in <controls> looked for its VML shape by scanning
every shape, so a sheet of 10,000 controls took over 8 s to open. The
shapes are now indexed by number once, and the same sheet opens in
about 0.4 s.
2026-10-31 16:11:55 -04:00
prxnvt 0e631e20da XLSX: a hostile-file test for form controls
A sheet with more controls than a sheet keeps (10,000), properties
parts that are not XML, are missing or hold numbers no control can take
(1e400, NaN, -inf, 2^64), shape ids that are not numbers, anchors far
outside the grid, and oversized names and links. Reading keeps exactly
the cap, every control passes the model's rules, and a save and reopen
keeps them all.
2026-10-31 16:11:55 -04:00
prxnvt b0531aeb73 XLSX: form control VML shapes are also found by their o:spid
Some producers give a VML shape a named id (`id="Button 1"`) and keep
its number in `o:spid`. The form control reader looked for the number
only in `id`, so a control listed in <controls> lost its caption and
macro (they live in the VML), and a control known only from the VML was
skipped. Shapes are now matched by `id`, else by `o:spid`, as
pass-through already does when it leaves the modelled shapes out.
2026-10-31 16:11:55 -04:00
prxnvt 09a37a8f10 Form controls: a kept button action comes back only on the same button
The button actions xlsx has no place for (commands, fixed inputs,
output cells) are kept in a hidden name keyed by sheet and the
control's place on the sheet. When another application added, removed
or reordered controls before saving, opening the file gave the action
to whichever button now had that place, and could replace a macro the
file itself gave that button.

Each entry now also holds the button's name and anchor cell, and is
restored only on a button with that name and cell whose own action from
the file does not contradict it: a stored macro action only on a button
that still runs that macro, a stored command only on a button the file
gives no macro.
2026-10-31 16:11:55 -04:00
prxnvt c7a11e9860 Form controls: a button's file command names its file in the approval question
Pressing a button whose command reads or writes a file asked "Run
file.exportCsv from 'Button 1'? It writes a file." without saying which
file, so the person could not tell what they were approving. The
question now ends with the command's `path` in parentheses; control
characters show as spaces and a path longer than 200 characters is
shortened in the middle.
2026-10-31 16:11:55 -04:00
prxnvt 7042306478 UI: Insert › Controls, form controls on the sheet, Assign Action and Format Control
The Insert tab gains Button, Slider and Spin Button beside Cell
Controls › Checkbox, which is unchanged. Each arms placement: a drag on
the grid draws the control at that size (a tall drag makes a vertical
slider), a click places it at its default size, and Escape disarms. Key
tips (N B U, N S L, N S B) place one at the active cell. A new button
opens Assign Action, as Excel opens Assign Macro.

form_controls.rs draws the controls in the sheet tokens, so they look
the same in both themes: buttons with hover and pressed states, sliders
with a track, a filled part and a thumb (and the value beside the thumb
while it is dragged), spin buttons with two arrows, stacked or side by
side. A plain click works a control: dragging the thumb, clicking the
track (a page), clicking or holding an arrow (it repeats after 0.4 s),
pressing a button (it runs on release over it). A drag or a hold is one
undo step. The control last worked takes the arrows, Page Up and Page
Down, Home and End, and Space or Enter for a button. Ctrl or Cmd+click
or a right-click selects a control to move or resize it; a right-click
opens the object menu, which for a control adds Edit Text and Assign
Action (buttons) and Format Control before Delete.
Double-clicking a selected control opens its editor. On a right-to-left
sheet a control moves as a whole and works where it is drawn.

Assign Action runs a macro (from the list or typed, on the selection or
a fixed range, with an optional output cell) or a command (searched
among those a macro may run, with JSON parameters). Format Control edits
the value, limits, incremental and page change, cell link, orientation,
name and the locked and print flags, checking the same rules as the
engine. A button whose action needs approval, or whose macro would write
over data, asks in a prompt whose buttons press it again with the
answer.

New theme tokens (thumb, hover, pressed, fill, focus ring) are in the
contrast test; Button, Slider and Spin icons are drawn in code; the new
labels are translated in every interface language. The catalog gains
Developer › Controls (280 / 296).
2026-10-31 16:11:54 -04:00
prxnvt 295c57535c XLSX: form controls (control properties, <controls> and VML ClientData), alongside kept controls
Buttons, scroll bars (sliders) and spin buttons are written the three
ways the published formats describe: a control properties part
(xl/ctrlProps/ctrlPropN.xml, formControlPr with objectType, fmlaLink,
min, max, inc, page, val and horiz), a <control> in the sheet's
<controls> inside mc:AlternateContent with its anchor, print and locked
flags and a button's macro="[0]!Name", and a VML shape with
x:ClientData. Notes and controls share one VML drawing per sheet; shape
ids come from 1024-id blocks listed in the drawing's idmap (after the
blocks kept header and footer drawings use), notes first, so each
control's shapeId names its VML shape.

Reading prefers the properties part, takes the caption from the VML
text box and falls back to the VML alone for files that only have it.
Values are clamped to whole numbers 0..30000 and repaired to pass the
model's rules.

Form controls of other kinds (check boxes, list boxes, drop-downs) stay
with pass-through: the reader returns the shape numbers it modelled,
and pass-through keeps the rest of the <controls> element and VML
drawing without them. On save, modelled controls join the kept
<controls> element and take a fresh shape block in the kept drawing, so
a sheet with both round-trips without duplicates. Properties part names
are reserved so kept parts of other sheets don't collide with them.

Button actions xlsx has no place for (commands, fixed inputs, output
cells) travel in a chunked hidden name keyed by sheet and position, and
are taken back out on open. The streaming sheet parser now splits only
shallow <row> elements, so an anchor's <xdr:row> inside <controls> stays
in the tree.
2026-10-31 16:11:54 -04:00
prxnvt a0d991e903 Form controls: model and commands for buttons, sliders and spin buttons
A sheet holds form controls as floating objects of a new kind
(ObjKind::Control): buttons that run a macro or one command, and sliders
and spin buttons that hold a whole number from 0 to 30000 and write it
to a linked cell. They take part in everything other objects do: ids
from the workbook sequence, object.move, object.delete and
object.setAnchorMode, the Selection Pane and Bring Forward / Send
Backward, anchors that follow row and column edits, fresh ids on a
copied sheet, and links that follow inserts, deletes, cut-moves, sheet
renames and sheet deletes like chart series.

Commands (cmd/controls.rs): insert.button, insert.slider,
insert.spinButton, control.format, control.assign, control.setValue,
control.press and the control.list query. Each numeric rule (0 <= min <
max <= 30000, 1 <= step <= max - min, page >= step for sliders) is
refused with its own message, and values are clamped and snapped to the
step. control.setValue writes the linked cell through cell.set inside
one undo group, so dependents recalculate and one Undo restores both;
every commit moves linked controls to what their cells hold.

control.press runs a button's macro through macro.run, or its command
through the macro policy with the document's trust, so a button in a
workbook opened from a file asks before it runs anything. Like a macro
step, the command runs inside the scripts' file sandbox unless it is an
approved file command. Macros never press controls, and macro.rename
keeps buttons pointing at the renamed macro.

macro.run, macro.check and the debugger take `output`, which replaces
the macro's output anchor for that run (a button that sends the output
elsewhere). The policy and the approval go by the stored macro; the
footprint, the conflict rule and the per-step check work on the cells
`output` names. A value that isn't reference text of at most 1,024
characters, or names no cell, is refused before anything runs.
2026-10-31 16:11:48 -04:00
prxnvt 548e5345ee Objects: only the selected object's handles resize; a press elsewhere on an object moves it
A primary drag looked for a handle under the press on whatever object it
grabbed, so pressing near the edge or corner of an object that was not
selected started a resize, while the pointer over that spot showed the
Move cursor (handles are only shown, and only offered by the cursor, on
the selected object). The press now takes its handle from
`chartview::selected_handle`, the same lookup the cursor uses; a press on
any other object selects and moves it.

Test: dragging an unselected shape by its bottom-right corner shows the
Move cursor, then selects and moves the shape without resizing it.
2026-10-31 12:53:46 -04:00
prxnvt f3fbaa5568 Objects: one chart evidence paragraph in gaps.md; trendlines are not missing
gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158
and #161 were open and a newer one saying all 2-D kinds survive XLSX round
trip, both repeating the resize, smooth-line, Switch Row/Column and
trendline statements. It is one paragraph with the current status again.

The Mainstream chart row in target-app-parity.md listed trendlines as
missing; they are read, written and drawn (`Series::trendlines`, the XLSX
chart reader and writer, `chart.addElement`), as chart-parity.md records.
The row now says so and keeps "no axis options". No score change.
2026-10-31 11:22:01 -04:00
prxnvt 977c895378 Objects: the parity and gap docs no longer list chart resizing as missing
gaps.md (§4), target-app-parity.md (the Essentials tier's work, the
Mainstream chart row, the interaction discount and the Essentials chart
task) and chart-parity.md (the element table and the work list) still
described charts as impossible to resize (#190). Charts move and resize
from eight handles like every floating object, so those statements now
say so; the 2026-10-10 revision rows and the 10-10 re-check stay as
history. Scores and the 40-60 h chart estimate are unchanged (resizing
was 1-2 h, within the rounding).
2026-10-31 09:22:01 -04:00
prxnvt 4b805d64bc Objects: each drag of an object is its own undo step
A drag merges its frames into one undo step by dropping a new step whose
label matches the one before it. Its first frame did the same, so a second
drag of an object (a move after a move, or a resize after a move) merged
into the first drag's "Move Object" step, and one undo took back both.

`Drag::Object` records whether the drag has made its step; only the later
frames of the same drag merge into it.

Test: a move and then a resize of a picture take two undos to reverse,
the first restoring the moved picture.
2026-10-31 06:19:13 -04:00
prxnvt 028ce2b417 Objects: a resize handle under the headers leaves the press to them
A selected object's handles reach a few points outside it, and a press
was matched against them before any check of where it landed, so a press
on the column or row header beside an object at the sheet's edge resized
the object instead of selecting the column or row. The cursor already
ignored handles outside the cells.

`chartview::selected_handle` finds the selected object's handle under a
point only over the cell area, as `hit` does, and not while objects are
hidden; the press and the cursor both use it.

Test: presses within a handle's reach over the column header and the row
header neither move nor resize the object.
2026-10-31 06:02:30 -04:00
prxnvt de8f1854f1 Objects: bubble sizes follow edits like the other series references
`rewrite_cell_links` rewrote a chart series' values, categories and name
but not its bubble sizes, so inserting or deleting lines, a cut-move or a
sheet rename left a bubble chart's sizes pointing at the old cells. They
are now rewritten with the rest.

Test: bubble sizes through an inserted row, a cut-move and a sheet rename.
2026-10-31 05:52:42 -04:00
prxnvt 2fce1a9b42 Objects: every kind follows edits; chart series follow cut-moves and sheet deletes
Structural edits and sorts kept three hand-indexed lists (charts, images,
shapes) in step in two places, and slicers were left out of both, so a
slicer stayed put when rows were inserted above it. Chart series were
rewritten for inserted and deleted lines and for sheet renames, but not for
cut-moves or sheet deletes, which left them pointing at the old cells.

- Inserting and deleting rows and columns and sorting walk
  `Sheet::object_anchors` and `object_anchors_mut`, one fixed order over
  every kind, so slicers now follow their cells by their anchor mode like
  the other objects, and a new kind cannot be forgotten in one of the
  passes.
- `rewrite_cell_links` is the one place that rewrites the references
  floating objects hold (chart series values, categories and names,
  and the source block a chart was made from).
  `rewrite_all_formulas` calls it, which covers inserting and deleting
  lines and cells and cut-moves on one sheet or across sheets; renaming and
  deleting a sheet call it too, so a series into a deleted sheet becomes
  #REF!, as a formula does.
- Copying a sheet gives its charts, pictures, shapes and slicers fresh ids,
  keeping ids unique across the workbook, which object selection and the
  `{kind, id}` commands rely on.

Tests: every kind through row and column inserts and deletes, an absolute
shape, a sort and undo; a slicer through inserted rows; chart series
through a cut-move, a sheet rename, a sheet delete and undo; ids after a
sheet copy.
2026-10-31 05:52:04 -04:00
prxnvt e7591de2ec Objects: one selection for charts, pictures, shapes and slicers, with eight resize handles
The sheet tracked the selected object as a bare id in `selected_chart`, so
only charts and text boxes drew a selection frame, only the bottom-right
corner resized, and the kind of the selected object had to be guessed from
the id. The selection is now `SheetApp::selected_object: Option<ObjRef>`,
with `selected_chart()` kept for the Chart Design tab and the Format Chart
pane.

- Model: `ObjKind` (chart, image, shape, slicer) and `ObjRef { kind, id }`;
  `Sheet` helpers find, move and remove an object by `ObjRef` and walk every
  object's anchor in a fixed order. `next_object_id` saturates instead of
  overflowing on an id at `u32::MAX` read from a file.
- Commands: `object.delete`, `object.move` and `object.setAnchorMode` accept
  the four kinds through one parameter check, refuse an unknown kind or id
  instead of succeeding silently, and clamp hostile offsets. `object.delete`
  replies with the deleted object. Arrange and the Selection Pane include
  slicers, which stack above the other objects.
- Sheet: the selected object of any kind draws a frame with eight handles
  over every object. Each handle resizes only its own edges, which never
  cross; the body moves the object, and the first drag frame already applies
  the pointer's movement. The pointer shows the handle's resize cursor or a
  move cursor, with the diagonals swapped on a right-to-left sheet, where the
  frame moves with its object.
- Keys: Escape drops the object selection; Delete and Backspace keep
  deleting the selected object (#213). A selection whose object disappears
  (undo, redo, delete, sheet switch) is cleared.
- Double-click: `open_object` routes by kind (a text box edits in place, a
  chart opens Format Chart).
- Control protocol: `ui.inspect` reports `selectedObject {kind, id}` and
  keeps `selectedChart`; `ui.object.select` selects any object and replies
  with its rect. The snapshot example takes `--object <id>`.

Tests: engine tests for every kind by kind and id, the Selection Pane and
hostile parameters; UI tests for selecting each kind (a slicer over a chart
takes the click), all eight handles on four kinds, cursors, Delete,
Backspace and Escape, undo and redo, double-click routing, and resizing on a
right-to-left sheet. Existing tests use the new selection API.
2026-10-31 05:51:02 -04:00
vangel76 33cadfc0a8 Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-30 00:54:56 +01:00
vangel76andClaude Sonnet 5.5 494a7dbb5c Keep clustered hairline bars of each series in their own strip of the pixel column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 00:54:56 +01:00
vangel76 95ba076778 Merge remote-tracking branch 'origin/perf/calc-spill-index-2683' into HEAD 2026-10-30 00:04:34 +01:00
vangel76andClaude Sonnet 5.5 bdbd29e623 perf(calc): size the spill-index decision by the sheet's own changed cells
A batch spread over several sheets used its total length for every sheet, so a
one-cell sheet with many spills built the block index as if it had the whole
batch's lookups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 00:04:25 +01:00
vangel76 edfde80f95 Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-30 00:02:22 +01:00
vangel76 54ba74cfc0 Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-29 23:50:34 +01:00
vangel76andClaude Sonnet 5.5 c1e1c7bdc9 Merge origin/perf/calc-spill-index-2683 into perf/calc-spill-unblock-2684
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 23:49:01 +01:00
vangel76andClaude Sonnet 5.5 6c912d2eb1 perf(calc): keep tall and wide spill areas out of the block index
A full-height column spill took 65,536 block registrations, on every pass
that produced it. Areas over 1024 blocks are now tested directly by each
lookup instead. Adds a randomized comparison against a scan of all areas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 23:48:11 +01:00
vangel76 1801f4a7cf Merge upstream/main into perf/calc-spill-unblock-2684 2026-10-29 23:09:25 +01:00
vangel76 c3865b5dbd Merge upstream/main into perf/calc-spill-index-2683 2026-10-29 23:07:21 +01:00
vangel76 95c3bb9b89 Fix clippy complexity after merging upstream/main 2026-10-29 23:05:54 +01:00
vangel76 72e959f117 Merge upstream/main into fix/chart-work-budgets 2026-10-29 23:04:21 +01:00
vangel76 bc7e37fa7d Merge upstream/main into fix/chart-combo-logic-cleanup 2026-10-29 23:03:18 +01:00
vangel76andClaude Sonnet 5.5 8b330ac9e2 Tighten selection comment and test index arithmetic
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:52:59 +01:00
vangel76andClaude Sonnet 5.5 b6bbdcd5cf calc: build precedents from a borrowed syntax tree, no Arc for text-only formulas
Formula::with_expr runs a closure on the cached tree, or on a freshly parsed
one that is dropped afterwards, so rebuild and cells_changed need neither a
tree copy nor an Arc allocation / reference count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:51:45 +01:00
vangel76andClaude Sonnet 5.5 4523b817ce Read already-sorted input directly instead of selecting
A full sort recognises ascending and descending runs in O(n), which a
selection cannot beat, so sorted, reverse-sorted and tie-block input was
up to 1.8x slower than before. Check for a monotone run first (one pass
that stops at the first out-of-order pair) and index into it. Extend the
bit-for-bit oracle test with ascending, descending and nearly ascending
data.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:51:12 +01:00
vangel76 d71cf10e70 Merge remote-tracking branch 'upstream/main' into perf/calc-expr-arc-2685 2026-10-29 00:48:45 +01:00
vangel76 b7c5a69dbd Merge remote-tracking branch 'upstream/main' into perf/stat-select-2679 2026-10-29 00:47:31 +01:00
vangel76andClaude Sonnet 5.5 f404a08357 Merge upstream/main into perf/calc-range-memo-2681
eval.rs and recalc.rs were split into submodules; re-applied the range_sum hunks in eval.rs, eval/call.rs and recalc/pass_host.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 00:54:49 +01:00
vangel76andClaude Sonnet 5.5 a434efabf0 Merge upstream/main into perf/stat-select-2679
stat.rs was split into stat/; re-applied the selection changes in stat/descriptive.rs and moved the oracle test to stat/tests.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 00:53:37 +01:00
vangel76andClaude Sonnet 5.5 2fe8a33c5d Merge upstream/main into fix/chart-combo-logic-cleanup
render.rs was split into render/ modules on main; the plot-kind and fixed-scale cleanups are re-applied in the new modules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:52:57 +01:00
vangel76andClaude Sonnet 5.5 2f36fe1b34 Merge upstream/main into perf/chart-measure-allocs
render.rs was split into render/ modules on main; the label-fitting, legend and treemap changes are re-applied in the new modules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:51:31 +01:00
vangel76andClaude Sonnet 5.5 5144ebe1de Merge upstream/main into fix/chart-work-budgets
render.rs was split into render/ modules on main; the budget and hairline-bar changes are re-applied in render.rs and render/cartesian.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:47:54 +01:00
vangel76andClaude Sonnet 5.5 5f3fcc8140 perf(calc): look up #SPILL! cells by row and column range, not by row band
The spill-error set was ranged by rows only and filtered by column afterwards, so an edit in
column A walked every #SPILL! cell to its right in the same rows. Each row now keeps its own
column set and a window lookup takes one column range per row that has an entry. Results are
unchanged. Also covers an array spilling right over the cleared cell.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:45:28 +01:00
vangel76andClaude Sonnet 5.5 356ccdcf1a perf(calc): index spills for a paste only when registering them costs less than testing them
A full-height column spill takes 65,536 blocks to register, which a 17-cell paste elsewhere
never needed. A sheet's spill areas are now indexed only when their block count is below what
testing each area against each changed cell would cost; otherwise the plain containment check
runs, as before. Results are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:43:47 +01:00
vangel76 0b7f674925 Merge upstream/main 2026-10-27 19:40:32 +01:00
Michael Higgins c31e4af95c Merge branch 'storytold:main' into fix/2170-shape-font-attr-escape 2026-10-27 11:17:04 -04:00
Higgs32584andClaude Opus 5.5 9ac6b67e18 Merge origin/main into fix/2181-link-open-confirmation
Port the #2181 link.open confirmation changes into main's split files:
cmd/insert.rs -> cmd/insert/links.rs (open_link), dialogs.rs -> dialogs/show.rs
(OK handling, open_confirmed_link), grid.rs -> grid/interact.rs (follow_link doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:54:30 -04:00
Michael Higgins de0c5a0d96 Merge branch 'storytold:main' into fix/2181-link-open-confirmation 2026-10-26 18:00:36 -04:00
Michael Higgins 793420b849 Merge branch 'storytold:main' into fix/2170-shape-font-attr-escape 2026-10-26 18:00:13 -04:00
vangel76andClaude Sonnet 5.5 72236e4aab perf(calc): find the formulas a cleared cell may unblock from a set of #SPILL! cells (#2684)
Clearing a cell walked every formula in the graph, and each changed cell
scanned a 64 x 64 window of stored cells, to find formulas showing
#SPILL!. Calc now keeps the set of cells whose value is #SPILL! (filled
in rebuild, kept up to date by cells_changed and the write-back of a
run) and takes those candidates from it. The same set replaces the walk
over the graph in run for areas a formula stopped spilling over. Each
entry is checked against the workbook before it is used.

Fixes #2684

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:26:15 +01:00
vangel76andClaude Sonnet 5.5 6c37a63704 perf(calc): index the pass's spill areas so reads don't scan every spill (#2683)
A cell read and a range read looked at every spill computed so far in
the pass, and cells_changed looked at every spill range of the sheet for
each changed cell. A spatial index (16 x 16 cell blocks) now finds the
areas covering a cell or meeting a range. Where areas overlap, the one
that counted before (first/last in the pass's spill map order) still
counts.

Fixes #2683

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:22:22 +01:00
vangel76andClaude Sonnet 5.5 c567511cf1 calc: rebuild the dependency graph from shared syntax trees
Calc::rebuild and Calc::cells_changed called Formula::expr(), which deep-copies each formula's
syntax tree only to read its references. Use expr_arc() (already used when formulas are
calculated), which hands out the cached Arc without copying.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:21:15 +01:00
vangel76andClaude Sonnet 5.5 96bfba93f8 perf(calc): remember SUM/COUNT/AVERAGE of constant-only blocks per pass (#2681)
A block that holds only constants can't change during a pass, so its sum
and count are worked out once and reused by every formula that reads the
same block. A block that is the same rows from the top but longer (a
running total) continues the shorter block's sum, which is the same
additions in the same order as one walk from the start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:17:29 +01:00
vangel76andClaude Sonnet 5.5 890870cd57 perf: select instead of sort in MEDIAN, PERCENTILE, QUARTILE, LARGE, SMALL
Read the needed order statistics with select_nth_unstable_by(total_cmp)
instead of a full sort. Results are bit-identical; a test compares them
against the previous full-sort code on randomised data.

Fixes #2679

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:11:08 +01:00
vangel76andClaude Sonnet 5.5 ee13457b2f Merge upstream/main into fix/chart-combo-logic-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:55:05 +01:00
vangel76andClaude Sonnet 5.5 8ae680547a Merge upstream/main into fix/chart-work-budgets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:54:05 +01:00
Michael Higgins 40929d80c5 Merge branch 'main' into fix/2181-link-open-confirmation 2026-10-25 10:21:07 -04:00
vangel76andClaude Sonnet 5.5 8f34c42e88 Merge upstream/main into fix/chart-combo-logic-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:22:03 +01:00
vangel76andClaude Sonnet 5.5 6c32845740 Merge upstream/main into fix/chart-work-budgets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:19:09 +01:00
RedBearAKandClaude Fable 5.1 0b7deaa3d8 XLSX: the XML element cap counts only elements kept in the tree
`parse_streaming` counts every element it attaches to a parent against
MAX_XML_NODES, but the cells of a worksheet are attached to their `row`
while the row is open, and the row is then handed to the callback and
dropped. So the count grew with every cell of a sheet although the tree
never held more than one row, and a workbook with a sheet of about one
million ordinary cells (12,274 rows by 103 columns) was reported damaged
after 6,310 rows since #2413. Shared strings, streamed by `si`, had the
same exposure: a table of about a million plain strings, or fewer
rich-text ones.

The count now returns to its value at the start of a split element when
that element is handed out, so a streamed part of any size passes while a
part that keeps a million elements in its tree is still refused.
Regression test: 5,001 streamed rows of 100 cells are read whole; the same
part kept in a tree is refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 17:45:15 -08:00
vangel76andClaude Opus 5.5 a34e109451 Merge upstream/main into perf/chart-measure-allocs
Also removes conflict markers committed to ROADMAP.md by the previous merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:53:52 +02:00
vangel76 c0d0d909fc Merge remote-tracking branch 'upstream/main' into fix/chart-work-budgets
# Conflicts:
#	ROADMAP.md
#	crates/chart/src/resolve.rs
2026-10-22 19:43:16 +02:00
vangel76 562004f198 Merge remote-tracking branch 'upstream/main' into perf/chart-measure-allocs
# Conflicts:
#	ROADMAP.md
2026-10-22 19:35:18 +02:00
vangel76 7f8ac83dc3 Merge remote-tracking branch 'upstream/main' into fix/chart-combo-logic-cleanup
# Conflicts:
#	ROADMAP.md
#	crates/chart/src/tests.rs
2026-10-22 19:34:40 +02:00
Higgs32584andClaude Opus 5.5 594557e000 Merge origin/main into fix/2181-link-open-confirmation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:57:34 -04:00
Higgs32584andClaude Opus 5.5 79214fd287 link: require confirmation for programmatic link.open (#2181)
link.open is reachable by the control channel, CLI, MCP and workbook
scripts, so it could open https://attacker/?d=<cell data> without the
user seeing it. By default (LinkPolicy::Confirm) it now refuses web and
e-mail addresses with an error and opens no dialog; references still
navigate and other schemes stay refused. A host may opt in with
LinkPolicy::Allow (Rust API only, no command sets it).

The UI's Open Hyperlink prompt no longer runs a command on OK: it calls
Session::open_confirmed_link with the address it showed, kept in a
private field, so ui.dialog.confirm can't press OK and ui.dialog.set
can't swap the address behind the prompt text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:50:07 -04:00
Higgs32584andClaude Opus 5.5 4ab3edaf3b xlsx: escape shape font typeface attribute (#2170)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:06:33 -04:00
9977d850a4 test(calc): Excel-verified blank results for VLOOKUP, HLOOKUP and LOOKUP (#374)
Excel 365 for Windows (2609, build 16.0.20430.20032) keeps a lookup of an
empty cell blank inside ISBLANK and concatenation for VLOOKUP, HLOOKUP and
LOOKUP. The cases were checked in Excel by scottcampbelldata in
vangel76/gridcraft#1.

Co-Authored-By: scottcampbelldata <251750033+scottcampbelldata@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:23:42 +02:00
vangel76andClaude Sonnet 5.5 4a1e6de303 Charts: work and memory are bounded by series x points; ranges are clipped before evaluation (#1147)
A chart keeps at most 2,000,000 series x points (every series and the
categories keep their first points), 16 trendlines per chart; columns and
bars narrower than 0.25 px are merged per pixel column; series references
are cut after the rows that can matter before calc evaluates them; the
moving average is linear; fit_trendline allocates no Vec per point;
render borrows clean data instead of cloning it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:21:24 +02:00
vangel76andClaude Sonnet 5.5 79cfd9b151 Charts: label fitting and legend measuring avoid hundreds of text layouts per render (#1149)
Binary search in Ctx::fit, early exit of the side-legend column measure,
no per-point Vecs in value_range/squarify, treemap carries the value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:14:02 +02:00
vangel76andClaude Sonnet 5.5 5cf0e24f43 Charts: one combo plot-kind rule, no fixed_scale helper, ticks off the step grid (#1146)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:12:43 +02:00
vangel76andClaude Sonnet 5.5 d446fde718 Merge upstream/main into fix/lookup-blank-result
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:05:24 +02:00
vangel76andClaude Sonnet 5.5 423810750d Functions: don't assert ISBLANK(VLOOKUP/HLOOKUP/LOOKUP), unverified against Excel (#374)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:18:01 +02:00
vangel76andClaude Opus 5.5 d2b9affbc5 Functions: test that arithmetic reads a blank lookup result as 0 (#374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:17:48 +02:00
vangel76andClaude Opus 5.5 691883f99c Functions: VLOOKUP/HLOOKUP/LOOKUP/XLOOKUP keep an empty target blank (#374)
A looked-up empty cell was turned into the number 0 inside the function,
so ISBLANK saw FALSE and & / CONCATENATE inserted "0". The value now stays
blank, like INDEX; a bare lookup still displays 0 as a formula's final result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:17:48 +02:00
344 changed files with 31507 additions and 7189 deletions
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+6
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "56 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -23,6 +27,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
@@ -42,3 +47,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+58 -7
View File
@@ -1,12 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# GridCraft — instructions for agents
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/excel/*.md`). Do not ask for it and do not stop when it is absent. Screenshots of Microsoft software stay in that notebook and are never committed.
## Assets and iconography — absolute rule
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
@@ -35,7 +85,6 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
@@ -44,8 +93,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- **For UI work, look at the result** (snapshot PNG) and, when available, compare with `plan/excel/observed-ui.md`. Without that optional notebook, verify the intended behaviour against the task and committed documentation.
- macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
@@ -70,3 +119,5 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+1
View File
@@ -12,6 +12,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
| Regional format data in `crates/core/src/region.rs` (data, no file of its own) | Unicode, Inc. (Unicode CLDR) | [cldr-json](https://github.com/unicode-org/cldr-json) (`cldr-numbers-full`, `cldr-dates-full`) and ICU's CLDR 48 data | Unicode License v3 (`Unicode-3.0`; full text in `NOTICE`) | Separators, month and weekday names, AM/PM words, currency symbols and date order of the built-in regions. No CLDR file is copied into this repository. |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
+1 -56
View File
@@ -1,56 +1 @@
# GridCraft — instructions for agents
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Assets and iconography — absolute rule
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
- Every asset in the repo is original work by GridCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it).
- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug.
- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original.
- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine.
- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`).
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
## Never crash
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
## Non-negotiables
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+259 -47
View File
@@ -220,7 +220,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -739,6 +739,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cmpv2"
version = "0.2.0"
@@ -757,7 +763,7 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"der",
"spki",
"x509-cert",
@@ -827,6 +833,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -907,15 +919,28 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-cfb"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
[[package]]
name = "craft-csv"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -935,10 +960,38 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-offcrypto"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"aes",
"base64 0.23.1",
"craft-cfb",
"craft-xml",
"getrandom 0.3.4",
"hmac",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
"zeroize",
]
[[package]]
name = "craft-opc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"craft-xml",
"craft-zip",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -949,10 +1002,15 @@ dependencies = [
"winsafe",
]
[[package]]
name = "craft-sheet"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -972,7 +1030,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"chrono",
"dunce",
@@ -982,6 +1040,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -995,10 +1054,30 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -1009,11 +1088,43 @@ dependencies = [
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "craft-xls"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"craft-cfb",
"craft-sheet",
"md-5",
"miniz_oxide 0.9.1",
"sha1 0.10.7",
"thiserror 2.0.21",
]
[[package]]
name = "craft-xml"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"quick-xml 0.41.0",
"thiserror 2.0.21",
]
[[package]]
name = "craft-zip"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
dependencies = [
"flate2",
"thiserror 2.0.21",
"zip 9.1.0",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1094,6 +1205,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
dependencies = [
"cmov",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1106,7 +1226,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"der_derive",
"flagset",
"pem-rfc7468",
@@ -1171,7 +1291,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.3",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
@@ -1346,6 +1468,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1413,6 +1548,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -1478,7 +1633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1829,9 +1984,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1969,7 +2126,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"arboard",
"chrono",
@@ -1981,6 +2138,7 @@ dependencies = [
"getrandom 0.4.3",
"gridcraft-engine",
"gridcraft-mcp",
"gridcraft-sandbox",
"gridcraft-ui-egui",
"image",
"log",
@@ -1996,7 +2154,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2008,7 +2166,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -2019,10 +2177,11 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
"gridcraft-sandbox",
"log",
"quick-xml 0.41.0",
"serde_json",
@@ -2031,7 +2190,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"chrono",
"js-sys",
@@ -2042,8 +2201,9 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"craft-csv",
"gridcraft-calc",
"gridcraft-chart",
"gridcraft-core",
@@ -2065,7 +2225,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -2074,7 +2234,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -2082,7 +2242,7 @@ dependencies = [
[[package]]
name = "gridcraft-math"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"ratex-font",
"ratex-layout",
@@ -2092,7 +2252,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -2101,7 +2261,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2114,23 +2274,35 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.16.0"
version = "0.19.1"
[[package]]
name = "gridcraft-sandbox"
version = "0.19.1"
dependencies = [
"landlock",
"libc",
"nix",
"seccompiler",
"thiserror 2.0.21",
]
[[package]]
name = "gridcraft-ui-egui"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"craft-ui",
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"epaint_default_fonts",
"gridcraft-chart",
@@ -2143,6 +2315,7 @@ dependencies = [
"skrifa",
"sys-locale",
"toml",
"unicode-bidi",
"web-sys",
"web-time",
"zbus",
@@ -2150,8 +2323,10 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"gridcraft-engine",
@@ -2167,16 +2342,19 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"aes",
"craft-cfb",
"craft-csv",
"craft-offcrypto",
"craft-opc",
"craft-xls",
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-math",
"gridcraft-model",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
"zip 3.0.0",
@@ -2281,6 +2459,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -2816,6 +3003,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "landlock"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
dependencies = [
"enumflags2",
"libc",
"thiserror 2.0.21",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -2910,6 +3108,16 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3507,7 +3715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3963,7 +4171,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4348,7 +4556,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4407,7 +4615,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4487,6 +4695,15 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "seccompiler"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884"
dependencies = [
"libc",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4713,7 +4930,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"const-oid",
"const-oid 0.9.6",
"der",
"digest 0.10.7",
"pem",
@@ -4787,7 +5004,7 @@ dependencies = [
"chrono",
"cmpv2",
"cms",
"const-oid",
"const-oid 0.9.6",
"der",
"hex",
"rand 0.10.3",
@@ -4826,7 +5043,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"cms",
"const-oid",
"const-oid 0.9.6",
"hex",
"pem",
"rustls-pki-types",
@@ -5158,7 +5375,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6151,7 +6368,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6429,7 +6646,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
dependencies = [
"flate2",
"fmt-derive",
@@ -6444,6 +6661,7 @@ dependencies = [
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
@@ -6550,7 +6768,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"der",
"sha1 0.10.7",
"signature",
@@ -6618,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"serde_json",
@@ -6879,7 +7097,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6967,11 +7184,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+17 -8
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.16.0"
version = "0.19.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/gridcraft"
# internal
gridcraft-core = { path = "crates/core" }
gridcraft-numfmt = { path = "crates/numfmt" }
gridcraft-sandbox = { path = "crates/sandbox" }
gridcraft-formula = { path = "crates/formula" }
gridcraft-functions = { path = "crates/functions" }
gridcraft-model = { path = "crates/model" }
@@ -25,10 +26,21 @@ gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
gridcraft-math = { path = "crates/math" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
# Every craft-libs crate (craft-update, craft-window and the file-format crates here, craft-ui in
# ui-egui, craft-print in the app, craft-web-api and craft-web-host in the web app) comes from one
# craft-libs revision.
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]).
craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# OPC zip packages (parts, relationships, the zip writer) under `crates/xlsx/src/package.rs`.
craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
# turn them into workbooks.
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# foundation
thiserror = "2"
@@ -43,9 +55,6 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
sha2 = { version = "0.11", default-features = false }
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
aes = { version = "0.9", default-features = false }
sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
@@ -71,7 +80,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+49
View File
@@ -275,6 +275,55 @@ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
The regional format data in crates/core/src/region.rs (separators, month and weekday names, AM/PM
words, currency symbols and date order) follows the Unicode Common Locale Data Repository (CLDR),
read from unicode-org/cldr-json and from ICU's CLDR 48 data, under the Unicode License v3.
Source: https://github.com/unicode-org/cldr-json
The following text is reproduced verbatim from cldr-json's LICENSE:
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2015-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
+22 -5
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-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-05 · **Change:** minor (CJK fallback text shares the Latin baseline; earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 281 / 296 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -43,8 +43,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort
and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text
isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Devanagari text needs shaping verification (Chinese text still reported in #142), charts lose their type and options on round trip, and
nobody has yet confirmed that Excel opens our output without a repair prompt across a real
corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)).
@@ -105,7 +104,7 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
| Simplified Chinese | zh-Hans | menus only | ~12% |
| Spanish | es | none | 0% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
| Arabic | ar | catalog pending; shared shaping/bidi works | 0% |
| French | fr | none | 0% |
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
| Indonesian | id | none | 0% |
@@ -135,6 +134,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) |
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
@@ -147,6 +148,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
| 2026-10-31 | System and embedded CJK fallback text shares the Latin baseline in cells, editors and window titles (#1265, #2621). Symbol and emoji alignment, manual row heights and clipping are unchanged |
| 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority |
| 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
@@ -176,6 +180,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
| 2026-10-14 | Set precision as displayed is applied: with the option on, formula results, spills and numbers typed or reformatted are stored as their number format shows them and formulas calculate from those values; `formulas.calculationOptions` gains `precisionAsDisplayed` (turning it on rounds existing constants; locked constants on a protected sheet keep their value; numbers written to a new sheet are rounded too) (#1343) |
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
@@ -184,6 +189,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
| 2026-10-13 | Charts: label shortening needs about nine text measurements instead of up to 400, side legends stop measuring once the column is at its cap and treemap layout avoids per-item allocation (#1149) |
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
@@ -193,7 +199,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 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) |
@@ -257,6 +266,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) |
| 2026-11-05 | minor | Reconcile upstream form-control progress with the CJK baseline update |
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
| 2026-11-02 | minor | Progress log: Python cells as data |
@@ -268,6 +280,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
| 2026-10-31 | minor | Progress log: Function Library |
| 2026-10-31 | minor | Progress log: shared CJK/Latin baselines in cells, editors and window titles |
| 2026-10-26 | minor | Progress log: Arabic bidi display and editing |
| 2026-10-26 | minor | Progress log: Arabic font fallback |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
@@ -286,6 +301,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
| 2026-10-14 | minor | Progress log: Set precision as displayed applied in calculation (#1343) |
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
@@ -297,6 +313,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row |
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
+1
View File
@@ -14,6 +14,7 @@ name = "gridcraft-cli"
path = "src/main.rs"
[dependencies]
gridcraft-sandbox = { workspace = true }
gridcraft-engine = { workspace = true }
gridcraft-mcp = { workspace = true }
serde_json = { workspace = true }
+38 -18
View File
@@ -214,7 +214,7 @@ pub fn main(args: &[String]) -> ExitCode {
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
}
}
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
let Discovery { files, failures: mut reports, .. } = collect_inputs(&a.positional);
reports.extend(files.iter().map(|p| {
let path = p.to_string_lossy().into_owned();
match gridcraft_engine::io::read_file(&path) {
@@ -233,6 +233,9 @@ pub fn main(args: &[String]) -> ExitCode {
#[derive(Default)]
struct Discovery {
files: Vec<PathBuf>,
/// Identity (dev+inode) of each collected file, so a rescan reached by a shallower path doesn't add it twice.
#[cfg(unix)]
file_ids: std::collections::HashSet<(u64, u64)>,
failures: Vec<FileReport>,
}
@@ -257,11 +260,11 @@ impl Discovery {
/// (`~$…`) are skipped.
fn collect_inputs(inputs: &[String]) -> Discovery {
let mut out = Discovery::default();
let mut seen = std::collections::HashSet::new();
let mut seen = std::collections::HashMap::new();
for i in inputs {
let p = PathBuf::from(i);
if p.is_dir() {
if !walk(&p, 0, &mut out, &mut seen) {
if !walk(&p, 0, false, &mut out, &mut seen) {
break;
}
} else if !out.add_file(p) {
@@ -277,19 +280,29 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
out
}
/// Returns false only when the global file budget stops traversal.
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
// A directory is visited once by identity (dev+inode on unix): several links to the same
// directory are aliases, not exponential branches of the scan. Other platforms keep the
// depth cap only.
/// Returns false only when the global file budget stops traversal. `seen` holds the shallowest
/// depth each directory was scanned from; `rescan` is set below a directory reached again from a
/// shallower path (its depth-limited subtree may now fit), where already collected files are skipped.
#[cfg_attr(not(unix), allow(unused_mut))]
fn walk(dir: &Path, depth: usize, mut rescan: bool, out: &mut Discovery, seen: &mut std::collections::HashMap<(u64, u64), usize>) -> bool {
// A directory is visited once by identity (dev+inode on unix) unless reached from a shallower
// path: several links to the same directory are aliases, not exponential branches of the scan
// (each directory is scanned at most MAX_DEPTH + 1 times). Other platforms keep the depth cap only.
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if let Ok(m) = std::fs::metadata(dir)
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
&& !seen.insert(id)
{
return true;
if let Ok(m) = std::fs::metadata(dir) {
let identity = (m.dev(), m.ino());
// A directory past the depth limit is not scanned, so it is not recorded: asked for
// by name later, it is walked from depth 0.
match seen.get(&identity) {
Some(&shallowest) if shallowest <= depth => return true,
Some(_) => rescan = true,
None => {}
}
if depth <= MAX_DEPTH {
seen.insert(identity, depth);
}
}
}
if depth > MAX_DEPTH {
@@ -324,13 +337,20 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
}
};
if metadata.is_dir() {
if !walk(&p, depth + 1, out, seen) {
if !walk(&p, depth + 1, rescan, out, seen) {
return false;
}
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if !out.file_ids.insert((metadata.dev(), metadata.ino())) && rescan {
continue;
}
}
if !out.add_file(p) {
return false;
}
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
&& !out.add_file(p)
{
return false;
}
}
true
+75 -3
View File
@@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024;
/// Most zip entries looked at while validating a written package.
const MAX_ENTRIES: usize = 200_000;
/// Decompressed bytes that comparing one package's relationship kinds may read in total: the
/// relationship parts themselves and the targets whose content is checked.
const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024;
/// 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.
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
@@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
Ok(buf)
}
enum PartError {
/// Reading the part would spend more than the remaining budget.
OverBudget,
Unreadable,
}
/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`.
fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result<Vec<u8>, PartError> {
let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?;
// One byte past what is left shows that the part doesn't fit.
let limit = MAX_XML_PART.min(budget.saturating_add(1));
let mut buf = Vec::new();
// Bytes read before a decompression error still count against the budget.
let res = f.take(limit).read_to_end(&mut buf);
let read = buf.len() as u64;
if read > *budget {
*budget = 0;
return Err(PartError::OverBudget);
}
*budget -= read;
res.map_err(|_| PartError::Unreadable)?;
Ok(buf)
}
/// Whether the producer flagged its cached results as stale with `<calcPr fullCalcOnLoad="1">`:
/// libraries that don't calculate write it, and store 0 or nothing as every formula's value.
pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool {
@@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
/// 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.
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
relationship_kinds_within(bytes, RELATIONSHIP_BUDGET)
}
/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship
/// parts alone need more: their kinds can't be listed completely.
fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> 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();
// Several relationships name the same part; each fresh check decompressed it again, so
// repeated references to one highly compressible part multiplied the work. The verdict is
// remembered per (part, named content) and the decompression the package can cause is
// budgeted as a whole (finding: repeated decompression).
// budgeted as a whole, the relationship parts included: many large, unreferenced rels parts
// would otherwise each spend a full per-part allowance.
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
let mut budget: u64 = 512 * 1024 * 1024;
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 };
let xml = match read_zip_part_within(&mut zip, n, &mut budget) {
Ok(xml) => xml,
Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")),
Err(PartError::Unreadable) => continue,
};
let Ok(els) = 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("");
@@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str,
buf.clear();
}
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::*;
/// A package with `count` relationship parts of `size` bytes each, none of them describing an
/// existing part.
fn many_rels(count: usize, size: usize) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let head = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom" Target="https://example.com" TargetMode="External"/>"#;
let tail = "</Relationships>";
let filler = " ".repeat(size.saturating_sub(head.len() + tail.len()));
for i in 0..count {
z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap();
z.write_all(head.as_bytes()).unwrap();
z.write_all(filler.as_bytes()).unwrap();
z.write_all(tail.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
#[test]
fn relationship_parts_count_against_the_decompression_budget() {
// Each part fits the per-part allowance, but together they need more than the budget.
let bytes = many_rels(8, 64 * 1024);
assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err());
let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap();
assert_eq!(kinds.get("custom").map(String::as_str), Some("custom"));
}
}
+43 -2
View File
@@ -11,8 +11,8 @@ fn discovery_reports_a_directory_read_error() {
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
std::fs::write(&path, "not a directory").unwrap();
let mut discovery = Discovery::default();
let mut seen = std::collections::HashSet::new();
assert!(walk(&path, 0, &mut discovery, &mut seen));
let mut seen = std::collections::HashMap::new();
assert!(walk(&path, 0, false, &mut discovery, &mut seen));
assert!(discovery.files.is_empty());
assert_eq!(discovery.failures.len(), 1);
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
@@ -31,6 +31,47 @@ fn discovery_reports_omissions_only_after_the_file_limit() {
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
}
/// A new directory under the temp dir: `create_dir` fails on a name somebody else made first.
#[cfg(unix)]
fn fresh_dir(tag: &str) -> PathBuf {
let mut n = 0u32;
loop {
let dir = std::env::temp_dir().join(format!("gridcraft-check-{tag}-{}-{n}", std::process::id()));
match std::fs::create_dir(&dir) {
Ok(()) => return dir,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
Err(e) => panic!("{e}"),
}
}
}
/// A directory named again by a deeper path, or by a link back to a parent, lists each workbook once.
#[cfg(unix)]
#[test]
fn discovery_lists_each_workbook_once_whatever_path_reaches_it() {
let dir = fresh_dir("once");
let sub = dir.join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(dir.join("a.xlsx"), b"x").unwrap();
std::fs::write(sub.join("b.xlsx"), b"x").unwrap();
std::os::unix::fs::symlink(&dir, sub.join("loop")).unwrap();
let names = |d: &Discovery| {
let mut v: Vec<String> = d.files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
v.sort();
v
};
// The link back to the top is one directory, not another branch of the scan.
let found = collect_inputs(&[dir.to_string_lossy().into_owned()]);
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
// `sub` named after its parent is reached from a shallower depth: scanned again, nothing listed twice.
let found = collect_inputs(&[dir.to_string_lossy().into_owned(), sub.to_string_lossy().into_owned()]);
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
// Named first, the same `sub` is the shallowest path and the parent's scan skips it.
let found = collect_inputs(&[sub.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()]);
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
let _ = std::fs::remove_dir_all(dir);
}
fn at(a1: &str) -> CellRef {
CellRef::parse(a1).unwrap()
}
+10
View File
@@ -11,6 +11,7 @@
//! gridcraft-cli functions [--json] [--search X] [--category C]
//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
//! gridcraft-cli send <port> <method> [json]
//! gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]
//! gridcraft-cli version | --version
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -30,6 +31,7 @@ macro_rules! outln {
}
mod check;
mod sandbox_check;
use gridcraft_engine::Session;
use serde_json::{Value, json};
@@ -49,6 +51,8 @@ const USAGE: &str = "usage:
gridcraft-cli functions [--json] [--search X] [--category C]
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
gridcraft-cli send <port> <method> [json]
gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]
whether this system confines programs (no program: a confined self-test)
gridcraft-cli version";
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any
@@ -62,6 +66,11 @@ fn stdout_failed(e: std::io::Error) -> ! {
}
fn main() -> ExitCode {
// A confined launcher run (and its probes) comes first, before anything else starts.
let raw: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
if let Some(code) = gridcraft_sandbox::handle_args(&raw) {
return ExitCode::from(u8::try_from(code).unwrap_or(1));
}
let args: Vec<String> = std::env::args().skip(1).collect();
let rest = args.get(1..).unwrap_or_default();
let r = match args.first().map(String::as_str) {
@@ -79,6 +88,7 @@ fn main() -> ExitCode {
Some("cat") => cat(rest),
Some("run") => run(rest),
Some("check") => return check::main(rest),
Some("sandbox-check") => return sandbox_check::main(rest),
Some("commands") => commands(rest),
Some("functions") => functions(rest),
Some("mcp") => mcp(rest),
+98
View File
@@ -0,0 +1,98 @@
//! `gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]`: whether
//! this system confines programs, for a person or a reviewer checking a machine.
//!
//! Without a program it starts a confined copy of itself that tries what the sandbox must deny
//! (reading outside the policy, writing, the network, starting a process) and what it must still
//! allow (a thread, reading an allowed file), and prints one line per attempt. With a program it
//! runs that program confined, with the given read and executable paths and the system's shared
//! libraries, and passes its output and exit status through; its own lines then go to standard
//! error. Every path is absolute. Exit status: 0 when every attempt is
//! as required (or the program succeeded), 1 otherwise (a refused launch included), 2 when GridCraft
//! has no sandbox for this kind of system.
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::ExitCode;
use gridcraft_sandbox::{Canary, Policy, REFUSED_EXIT_CODE, REPORT_FLAG, Support};
pub fn main(args: &[String]) -> ExitCode {
match run(args) {
Ok(code) => code,
Err(e) => {
eprintln!("gridcraft-cli: sandbox-check: {e}");
ExitCode::FAILURE
}
}
}
fn run(args: &[String]) -> Result<ExitCode, String> {
let mut policy = Policy::new().with_system_libraries();
let mut it = args.iter();
let mut program: Option<(PathBuf, Vec<OsString>)> = None;
while let Some(a) = it.next() {
match a.as_str() {
"--read" => policy = policy.read(it.next().ok_or("--read needs a path")?),
"--exec" => policy = policy.exec(it.next().ok_or("--exec needs a path")?),
"--" => {
let p = it.next().ok_or("a program follows `--`")?;
program = Some((PathBuf::from(p), it.by_ref().map(OsString::from).collect()));
}
other => return Err(format!("unknown option `{other}`")),
}
}
// With a program, its standard output is passed through untouched, so this command's own
// lines go to standard error; the self-test's report is the output, on standard output.
let report = program.is_none();
let say = |line: String| {
if report {
outln!("{line}");
} else {
eprintln!("{line}");
}
};
match gridcraft_sandbox::support() {
Support::Available(how) => say(format!("sandbox: available ({how})")),
Support::Unsupported(why) => {
say(format!("sandbox: unsupported ({why})"));
return Ok(ExitCode::from(2));
}
}
let canary = Canary::create().map_err(|e| e.to_string())?;
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let (program, program_args) = match program {
Some((p, a)) => (p, a),
// A confined copy of this program, reporting each attempt; it reads its own executable as
// the allowed file.
None => {
let home = std::env::var_os("HOME").unwrap_or_default();
(exe.clone(), vec![REPORT_FLAG.into(), canary.file().into(), exe.clone().into(), home])
}
};
let policy = policy.exec(program.clone());
let mut cmd = gridcraft_sandbox::launch_command(&policy, &canary, &program, &program_args).map_err(|e| e.to_string())?;
let status = cmd.status().map_err(|e| format!("couldn't start the launcher: {e}"))?;
drop(canary);
match status.code() {
Some(0) => {
say(if report { "result: every attempt is as the sandbox requires" } else { "result: the program ran confined and succeeded" }.into());
Ok(ExitCode::SUCCESS)
}
Some(REFUSED_EXIT_CODE) => {
// The launcher's refusal and a program's own exit share the code; the launcher's reason,
// when it refused, is the `gridcraft-sandbox: refused:` line above.
say(format!(
"result: exit code {REFUSED_EXIT_CODE}: the launcher refused to run the program (its reason is above), or the program itself exited with {REFUSED_EXIT_CODE}"
));
Ok(ExitCode::FAILURE)
}
other => {
say(format!(
"result: {} ({})",
if report { "some attempts are not as the sandbox requires" } else { "the program failed" },
other.map_or("no exit code".into(), |c| format!("exit code {c}"))
));
Ok(ExitCode::FAILURE)
}
}
}
+38
View File
@@ -205,6 +205,44 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn a_directory_past_the_depth_limit_is_still_scanned_when_named_as_an_input() {
let dir = tmpdir("check-depth-named");
// The first folder past the limit (33 levels down): the scan from the top reports it unreached.
let mut deep = dir.clone();
for _ in 0..33 {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
ok(&["run", "--out", deep.join("deep.xlsx").to_str().unwrap(), "--quiet"]);
let out = run(&["check", dir.to_str().unwrap(), deep.to_str().unwrap(), "--json"]);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
// The first scan reports the subtree it could not reach; the named directory is checked.
assert_eq!(report["totals"]["opened"], 1, "{report}");
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn a_directory_first_reached_at_the_depth_limit_is_completed_when_named_as_an_input() {
let dir = tmpdir("check-depth-shallower");
// The 32nd folder is scanned from the top, but its child (and the workbook in it) is past the limit.
let mut limit = dir.clone();
for _ in 0..32 {
limit.push("d");
}
let child = limit.join("c");
std::fs::create_dir_all(&child).unwrap();
ok(&["run", "--out", limit.join("at_limit.xlsx").to_str().unwrap(), "--quiet"]);
ok(&["run", "--out", child.join("below.xlsx").to_str().unwrap(), "--quiet"]);
let out = run(&["check", dir.to_str().unwrap(), limit.to_str().unwrap(), "--json"]);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
// Named from depth 0 the subtree is read: the workbook below the limit is checked, the one at it only once.
assert_eq!(report["totals"]["opened"], 2, "{report}");
let _ = std::fs::remove_dir_all(dir);
}
/// A script line whose parameters are under a key `run` does not read used to run the command
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
+1
View File
@@ -1,3 +1,4 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod cli;
mod sandbox;
mod text_to_columns_quotes;
+384
View File
@@ -0,0 +1,384 @@
//! The confined launcher, through the real binary: what a confined program can't do, what it
//! still can, and that a launch whose check fails runs nothing. Linux and macOS confine; other
//! systems report that they can't.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
/// Whether this machine confines programs: a supported system whose self-test passes. On a Linux
/// or macOS machine that reports no sandbox (another architecture, no `sandbox-exec`) or whose
/// kernel doesn't enforce Landlock, the confinement tests have nothing to check and pass with a
/// note. On CI (`CI` set) the machine must confine, so a failure there is never skipped.
fn confines() -> bool {
static CONFINES: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CONFINES.get_or_init(|| {
if !cfg!(any(target_os = "linux", target_os = "macos")) {
return false;
}
let o = cli(&["sandbox-check"]);
let out = text(&o);
let missing = out.contains("sandbox: unsupported") || out.contains("Landlock is not enforced");
if missing && std::env::var_os("CI").is_none() {
eprintln!("this machine doesn't confine programs; skipping:\n{out}");
return false;
}
true
})
}
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).args(args).output().unwrap()
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// A directory of the test's own in the temporary directory, with one file.
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().canonicalize().unwrap().join(format!("gridcraft-sandbox-it-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("allowed.txt"), "allowed text\n").unwrap();
dir
}
/// `cat` as this system names it.
fn cat() -> &'static str {
if Path::new("/bin/cat").exists() { "/bin/cat" } else { "/usr/bin/cat" }
}
#[test]
fn the_self_test_finds_every_denial() {
// A home directory of the test's own, so the home-directory attempt always runs.
let home = scratch("home");
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).arg("sandbox-check").env("HOME", &home).output().unwrap();
let out = text(&o);
if !cfg!(any(target_os = "linux", target_os = "macos")) {
assert_eq!(o.status.code(), Some(2), "{out}");
assert!(out.contains("sandbox: unsupported"), "{out}");
return;
}
if !confines() {
return;
}
assert!(o.status.success(), "{out}");
for line in [
"denied: read the canary",
"denied: change the canary's permissions",
"denied: truncate the canary",
"denied: write next to the canary",
"denied: write in the temporary directory",
"denied: TCP connection",
"denied: start a process",
"denied: signal another process",
"denied: UDP socket",
"denied: list the home directory",
"works: start a thread",
"works: read an allowed file",
] {
assert!(out.contains(line), "missing `{line}` in:\n{out}");
}
std::fs::remove_dir_all(home).unwrap();
}
#[test]
fn a_confined_program_reads_only_what_it_is_given() {
if !confines() {
return;
}
let dir = scratch("read");
let outside = scratch("outside");
let d = dir.to_string_lossy().into_owned();
let allowed = dir.join("allowed.txt").to_string_lossy().into_owned();
let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &allowed]);
assert!(o.status.success(), "{}", text(&o));
// The program's output is passed through untouched; this command's own lines go to stderr.
assert_eq!(String::from_utf8_lossy(&o.stdout), "allowed text\n", "{}", text(&o));
assert!(String::from_utf8_lossy(&o.stderr).contains("result: the program ran confined"), "{}", text(&o));
// The same program, a file outside the policy: the read is refused and nothing is printed.
let other = outside.join("allowed.txt").to_string_lossy().into_owned();
let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &other]);
assert!(!o.status.success(), "{}", text(&o));
assert!(!String::from_utf8_lossy(&o.stdout).contains("allowed text"), "{}", text(&o));
std::fs::remove_dir_all(dir).unwrap();
std::fs::remove_dir_all(outside).unwrap();
}
#[test]
fn a_launch_whose_check_fails_runs_nothing() {
// A run that says it is already confined and isn't finds the canary readable and must refuse.
// On macOS that is the second stage started without `sandbox-exec`; on Linux the launcher never
// uses that flag, and the same refusal must hold.
let canary = scratch("canary");
let echo = if Path::new("/bin/echo").exists() { "/bin/echo" } else { "/usr/bin/echo" };
let probe = canary.join("allowed.txt").to_string_lossy().into_owned();
// A dynamically linked `echo` needs the system's shared libraries and its loader.
// The second stage takes the process the first looked at: here, the parent of what `cli` starts.
let mut launch = vec![
"--sandbox-confined".to_string(),
std::process::id().to_string(),
"--exec".into(),
echo.into(),
"--exec".into(),
env!("CARGO_BIN_EXE_gridcraft-cli").into(),
];
for lib in ["/lib", "/lib64", "/usr/lib", "/usr/lib64"].into_iter().filter(|d| Path::new(d).exists()) {
launch.extend(["--exec".to_string(), lib.into()]);
}
launch.extend(["--probe".to_string(), probe.clone(), "--".into(), echo.into(), "the program ran".into()]);
let o = cli(&launch.iter().map(String::as_str).collect::<Vec<_>>());
let out = text(&o);
assert_eq!(o.status.code(), Some(97), "{out}");
assert!(!out.contains("the program ran"), "{out}");
assert!(out.contains("gridcraft-sandbox: refused"), "{out}");
assert!(out.contains("read the canary"), "the check refused, not the arguments: {out}");
// Without that process, the second stage has nothing to try a signal on.
let without: Vec<&str> = std::iter::once("--sandbox-confined").chain(launch[2..].iter().map(String::as_str)).collect();
let o = cli(&without);
assert_eq!(o.status.code(), Some(97), "{}", text(&o));
assert!(text(&o).contains("needs the process"), "{}", text(&o));
// The normal path: the launcher confines itself first, and the program runs.
if confines() {
launch.splice(0..2, ["--sandbox-launch".to_string()]);
// The launcher may be under a folder in the list, on either system.
let mut by_folder = launch.clone();
by_folder[4] = Path::new(env!("CARGO_BIN_EXE_gridcraft-cli")).parent().unwrap().to_string_lossy().into_owned();
let o = cli(&by_folder.iter().map(String::as_str).collect::<Vec<_>>());
assert!(o.status.success(), "{}", text(&o));
assert!(text(&o).contains("the program ran"), "{}", text(&o));
let o = cli(&launch.iter().map(String::as_str).collect::<Vec<_>>());
let out = text(&o);
assert!(o.status.success() && out.contains("the program ran"), "{out}");
}
// A canary inside an allowed path is refused before anything runs, everywhere.
let dir = canary.to_string_lossy().into_owned();
let o = cli(&["--sandbox-launch", "--read", &dir, "--exec", echo, "--probe", &probe, "--", echo, "the program ran"]);
assert_eq!(o.status.code(), Some(97), "{}", text(&o));
assert!(!text(&o).contains("the program ran"));
std::fs::remove_dir_all(canary).unwrap();
}
/// A Python found on this system: the interpreter to run confined, and its installation, the path it
/// needs to read. `None` when there is no `python3`.
fn system_python() -> Option<(String, String)> {
// On macOS `/usr/bin/python3` is a stub that starts Xcode's Python, outside any read path given
// here; an installed Python comes first.
let candidates = if cfg!(target_os = "macos") {
["/opt/homebrew/bin/python3", "/usr/local/bin/python3"]
} else {
["/usr/bin/python3", "/usr/local/bin/python3"]
};
let python = candidates.into_iter().find(|p| Path::new(p).exists())?;
let prefix = Command::new(python).args(["-c", "import sys; print(sys.base_prefix)"]).output().unwrap();
let prefix = String::from_utf8_lossy(&prefix.stdout).trim().to_string();
// A framework build's `bin/python3` only starts `Python.app`'s interpreter as a new process,
// which the sandbox refuses; the interpreter itself is run instead.
let framework = Path::new(&prefix).join("Resources/Python.app/Contents/MacOS/Python");
let real = if framework.exists() { framework } else { std::fs::canonicalize(python).unwrap() };
Some((real.to_string_lossy().into_owned(), prefix))
}
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
/// terminal, since its standard input isn't one.
#[test]
fn a_confined_program_leaves_other_processes_alone() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import os, signal, sys, threading
other = int(sys.argv[1])
def refused(call, *args):
try:
call(*args)
except PermissionError:
return True
except OSError as e:
print("other error", call, args, e)
return False
assert refused(os.kill, other, 0), "a signal to another process"
os.kill(os.getpid(), 0)
seen = []
signal.signal(signal.SIGUSR1, lambda *_: seen.append(1))
signal.raise_signal(signal.SIGUSR1)
worker = threading.Thread(target=lambda: signal.pthread_kill(threading.main_thread().ident, signal.SIGUSR1))
worker.start()
worker.join()
assert len(seen) >= 1, "a signal to itself"
if sys.platform == "linux":
import ctypes, errno, fcntl, resource, termios
libc = ctypes.CDLL(None, use_errno=True)
assert libc.shmget(0, 4096, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "System V shared memory"
assert libc.msgget(0, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V message queue"
assert libc.semget(0, 1, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V semaphore"
if hasattr(libc, "mq_open"):
assert libc.mq_open(b"/gridcraft-check", os.O_RDONLY) == -1 and ctypes.get_errno() == errno.EPERM, "a POSIX message queue"
# macOS answers these for the processes the profile lets it signal, which are only itself.
assert refused(os.kill, -1, 0), "a signal to every process"
assert refused(os.kill, 0, 0), "a signal to the process group"
assert refused(resource.prlimit, other, resource.RLIMIT_NOFILE), "another process's limits"
assert refused(os.setpriority, os.PRIO_PROCESS, other, 0), "another process's priority"
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
assert refused(os.pidfd_open, other), "a handle on another process"
resource.prlimit(0, resource.RLIMIT_NOFILE)
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
assert refused(fcntl.ioctl, 0, termios.TIOCSTI, b"x"), "typing into a terminal"
assert refused(fcntl.ioctl, 0, termios.TIOCSTI | (1 << 32), b"x"), "the same with high bits set"
assert refused(fcntl.ioctl, 0, 0x541C, b"x"), "TIOCLINUX"
print("left alone")
"#;
let me = std::process::id().to_string();
let o = cli(&["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me]);
assert!(o.status.success(), "{}", text(&o));
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
}
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
/// Skipped when there is no `python3`.
#[test]
fn a_confined_program_changes_no_file() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import errno, fcntl, os, struct, sys
readable, outside = sys.argv[1:3]
def refused(what, call, *args):
try:
call(*args)
except PermissionError:
return
except OSError as e:
sys.exit(f"{what}: another error, not a refusal: {e}")
sys.exit(f"{what}: it worked")
fd = os.open(readable, os.O_RDONLY)
for target in (readable, outside, fd):
refused(f"permissions of {target}", os.chmod, target, 0)
refused(f"owner of {target}", os.chown, target, os.getuid(), os.getgid())
refused(f"times of {target}", os.utime, target, (0, 0))
if sys.platform == "linux":
refused(f"setting an attribute of {target}", os.setxattr, target, "user.gridcraft", b"1")
refused(f"removing an attribute of {target}", os.removexattr, target, "user.gridcraft")
for path in (readable, outside):
refused(f"truncate {path}", os.truncate, path, 0)
refused(f"open {path} with O_TRUNC", os.open, path, os.O_RDONLY | os.O_TRUNC)
refused(f"open {path} to write", os.open, path, os.O_WRONLY)
# A descriptor opened to read can't be truncated either way; the file's contents are checked after.
try:
os.ftruncate(fd, 0)
except OSError:
pass
if sys.platform == "linux":
import ctypes
libc = ctypes.CDLL(None, use_errno=True)
# Newer calls, which the kernel running this may not have: the filter answers before it would.
for name, number in (("fchmodat2", 452), ("setxattrat", 463), ("removexattrat", 466), ("file_setattr", 469)):
ctypes.set_errno(0)
result = libc.syscall(number, -100, readable.encode(), 0, 0)
assert result == -1 and ctypes.get_errno() == errno.EPERM, f"{name}: {result}, {ctypes.get_errno()}"
# On a descriptor opened only to read, these change the file's flags and attributes.
refused("FS_IOC_SETFLAGS", fcntl.ioctl, fd, 0x40086602, struct.pack("l", 0))
refused("FS_IOC_FSSETXATTR", fcntl.ioctl, fd, 0x401C5820, bytes(28))
refused("FS_IOC_SET_ENCRYPTION_POLICY", fcntl.ioctl, fd, 0x800C6613, bytes(12))
# What a program still needs: its own descriptors' flags, and a file of its own in memory.
os.set_inheritable(fd, True)
os.set_inheritable(fd, False)
os.set_blocking(fd, False)
own = os.memfd_create("gridcraft-check")
os.ftruncate(own, 16)
assert os.fstat(own).st_size == 16
print("unchanged")
"#;
let inside = scratch("unchanged-inside");
let elsewhere = scratch("unchanged-outside");
let (readable, outside) = (inside.join("allowed.txt"), elsewhere.join("allowed.txt"));
let state = |file: &Path| {
let m = std::fs::metadata(file).unwrap();
(std::fs::read(file).unwrap(), m.permissions(), m.modified().unwrap())
};
let before = (state(&readable), state(&outside));
let d = inside.to_string_lossy().into_owned();
let (r, x) = (readable.to_string_lossy().into_owned(), outside.to_string_lossy().into_owned());
let o = cli(&["sandbox-check", "--read", &prefix, "--read", &d, "--", &python, "-I", "-c", code, &r, &x]);
assert!(o.status.success(), "{}", text(&o));
assert!(String::from_utf8_lossy(&o.stdout).contains("unchanged"), "{}", text(&o));
assert_eq!((state(&readable), state(&outside)), before);
std::fs::remove_dir_all(inside).unwrap();
std::fs::remove_dir_all(elsewhere).unwrap();
}
/// A Python found on this system starts and imports its standard library while confined, with
/// read access to its installation only. Skipped when there is no `python3`.
#[test]
fn python_starts_confined() {
if !confines() {
return;
}
let Some((real, prefix)) = system_python() else {
return;
};
let o =
cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import json, math, datetime, decimal; print(json.dumps([math.pi > 3]))"]);
assert!(o.status.success(), "{}", text(&o));
assert!(String::from_utf8_lossy(&o.stdout).contains("[true]"), "{}", text(&o));
// Confined Python can't open the network or a file outside its installation.
let o = cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import socket; socket.create_connection(('127.0.0.1', 9), 2)"]);
assert!(!o.status.success(), "{}", text(&o));
assert!(text(&o).contains("PermissionError") || text(&o).contains("Operation not permitted"), "{}", text(&o));
}
#[test]
fn a_confined_program_inherits_no_secrets() {
if !confines() {
return;
}
let env = if Path::new("/usr/bin/env").exists() { "/usr/bin/env" } else { "/bin/env" };
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["sandbox-check", "--", env])
.env("GRIDCRAFT_CONTROL_TOKEN", "secret-token")
.env("AWS_SECRET_ACCESS_KEY", "secret-key")
.env("LANG", "en_US.UTF-8")
.output()
.unwrap();
let out = text(&o);
assert!(o.status.success(), "{out}");
assert!(!out.contains("secret-"), "the environment reached the program:\n{out}");
assert!(out.contains("LANG=en_US.UTF-8"), "the locale is kept:\n{out}");
assert!(out.contains("PATH=/usr/bin:/bin"), "{out}");
}
/// A descriptor the parent leaves open (here fd 9, opened by a shell on a file outside any policy)
/// would survive `exec` with access the sandbox doesn't revoke, so the launcher closes it: the
/// program runs, and can't read through it. (A desktop app's system libraries leave such files open,
/// so refusing to run would block Python there.)
#[test]
fn an_inherited_descriptor_never_reaches_the_program() {
if !confines() {
return;
}
let outside = scratch("leak");
let leaked = outside.join("allowed.txt").to_string_lossy().into_owned();
let cli = env!("CARGO_BIN_EXE_gridcraft-cli");
// The shell opens fd 9 (not close-on-exec) and then becomes the command, which inherits it. The
// confined shell then tries to read through it, with a built-in: starting `cat` is refused. Not
// fd 3: on macOS the system's libraries hold a socket of their own there under the sandbox.
let bash = if cfg!(target_os = "macos") { "--exec /bin/bash" } else { "" };
let program = "read line <&9 && echo \"leaked: $line\"; echo finished";
let script = format!("exec 9<'{leaked}'; exec '{cli}' sandbox-check {bash} -- /bin/sh -c '{program}'");
let o = Command::new("/bin/sh").args(["-c", &script]).output().unwrap();
let out = text(&o);
assert!(out.contains("finished"), "the program ran:\n{out}");
assert!(!out.contains("leaked:"), "the program read through the descriptor:\n{out}");
std::fs::remove_dir_all(outside).unwrap();
}
+5 -1
View File
@@ -16,6 +16,10 @@ path = "src/main.rs"
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -26,4 +30,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }
+151
View File
@@ -0,0 +1,151 @@
//! `window.gridcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is GridCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
//! applying a command the page was told had failed.
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::sync::mpsc::{Receiver, TryRecvError};
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
#[cfg(target_arch = "wasm32")]
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
struct Pending {
reply: Receiver<Value>,
state: Arc<AtomicU8>,
answered: bool,
}
impl Pending {
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "the app dropped the request"}))
}
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if !self.answered {
ControlRequest::abandon(&self.state);
}
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::channel;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use gridcraft_ui_egui::{ControlRequest, SheetApp};
use serde_json::json;
use wasm_bindgen_futures::JsFuture;
use super::{POLL_MS, Pending};
/// The app id in `window.<app>`.
const APP: &str = "gridcraft";
/// Give the app its control queue and put `window.gridcraft` on the page. An existing
/// `window.gridcraft` keeps the functions it already has.
pub fn install(app: &mut SheetApp, ctx: egui::Context) {
let (queue, requests) = channel::<ControlRequest>();
app.control_rx = Some(requests);
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
if queue.send(control).is_err() {
pending.answered = true;
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(async move {
loop {
if let Some(reply) = pending.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// `ControlRequest`'s `state` values (`gridcraft_ui_egui::control`): the app has started it, or its
/// sender gave up.
const TAKEN: u8 = 1;
const ABANDONED: u8 = 2;
fn pending() -> (Pending, ControlRequest) {
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
(Pending { reply, state: control.state.clone(), answered: false }, control)
}
#[test]
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
let (waiting, control) = pending();
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
assert!(!control.take(), "the app must not run a request the page was told had failed");
}
#[test]
fn an_answered_call_is_not_released() {
let (mut waiting, control) = pending();
assert!(control.take());
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
}
#[test]
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
let (mut waiting, control) = pending();
assert_eq!(waiting.poll(), None);
drop(control);
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
}
+68 -91
View File
@@ -1,57 +1,64 @@
//! 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`:
//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks
//! in it and stores what it saves instead of the browser downloading. The messages and the two
//! transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the other Craft
//! apps' bridges, with the `gridcraft:` prefix. This module is GridCraft's part of it.
//!
//! - `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.
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `gridcraft:ready` once the app is listening; send a file now. GridCraft reads bytes, so it
//! says `file: false` and a host sends `bytes`.
//! - `gridcraft:open` (from the host): `{ name, bytes }`. It opens like a workbook picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `gridcraft:save` (to the host): `{ name, saveAs, bytes }` for every file GridCraft would
//! otherwise download: Save and Save As (an .xlsx workbook) and Export as PDF. The host stores
//! it and reports failures itself. GridCraft doesn't tell the host which saves make a file the
//! document, so `saveAs` is always `false`.
//! - `gridcraft:dirty` whenever unsaved changes appear or go away, `gridcraft:failed` 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.
//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF
//! exports are handed to the page rather than downloaded, and `?author=` names the user for new
//! notes and comments.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
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.
use crate::hostmode::open_name;
/// The app id in the host messages (`gridcraft:ready`).
const APP: &str = "gridcraft";
/// Saved files that count as a workbook: the document is then clean under the name it was saved as.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// The largest workbook taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// 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.
host: Rc<Host>,
/// Names of the files handed to the host 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() })
/// The bridge for this page, or `None` when GridCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), saved: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// Send saved and exported files to the host 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);
@@ -72,15 +79,16 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
/// 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.
/// After each frame: a workbook handed to the host this frame counts as saved under the
/// name it was saved as, and the host hears when unsaved changes appear or go away (the
/// crate does not repeat a state it already reported).
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))
@@ -91,76 +99,45 @@ impl Bridge {
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));
}
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_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:?}")))?;
let name = file_name(path);
self.host.post_save(&name, bytes, None, false)?;
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;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
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())
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim).
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; GridCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((open_name(name), array.to_vec()));
ctx.request_repaint();
}
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
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
+28
View File
@@ -0,0 +1,28 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::prefs`]): the name the bridge fills in when a host's open request has none.
//! Which pages host the app is `craft-web-host`'s.
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
/// name and falls back to this).
const NAMELESS: &str = "document";
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
pub fn open_name(name: &str) -> String {
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
assert_eq!(open_name("Budget.xlsx"), "Budget.xlsx");
assert_eq!(open_name("Sheet2!Book.xlsx"), "Sheet2!Book.xlsx");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
}
}
+13 -4
View File
@@ -10,15 +10,24 @@
//!
//! 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. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`,
//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods
//! (`api`).
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
//! editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(target_arch = "wasm32")]
mod web;
+8 -4
View File
@@ -37,9 +37,12 @@ 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 };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing GridCraft (such
// as a Nextcloud app), opens workbooks in it and stores what it saves (`host`). Unframed
// there is no parent, so that URL on its own stays the normal app with downloads.
// `data-host="page"` on the canvas opts a page in instead: it runs GridCraft in its own
// document and talks over DOM `CustomEvent`s on the canvas.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
@@ -48,7 +51,7 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
// In host mode the host 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}));
@@ -96,6 +99,7 @@ pub fn start() {
}
bridge.post_ready();
}
crate::api::install(&mut app, cc.egui_ctx.clone());
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
}),
)
+2 -1
View File
@@ -13,6 +13,7 @@ workspace = true
[dependencies]
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
gridcraft-sandbox = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
egui = { workspace = true }
serde_json = { workspace = true }
@@ -34,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
+9
View File
@@ -64,6 +64,9 @@ impl eframe::App for App {
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
// A ready update installs once the process has exited. The macOS exit below skips
// dropping the app, so the helper is started here.
self.0.start_pending_update();
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
@@ -238,6 +241,12 @@ fn icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result<()> {
// A confined launcher run (`gridcraft_sandbox::launch_command`) comes before everything else:
// it opens no window, writes no log, and becomes the program it confines.
let raw: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
if let Some(code) = gridcraft_sandbox::handle_args(&raw) {
std::process::exit(code);
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
// Commands catch their panics (`Session::execute`), but the default hook only prints to
+5 -1
View File
@@ -33,7 +33,8 @@ sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
sync_github_name = true
display_name = "vangel76"
sync_github_name = false
[people.MacJedi42]
sync_github_name = true
@@ -109,3 +110,6 @@ sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.RockerHX]
sync_github_name = true
+226 -8
View File
@@ -39,6 +39,15 @@ const MAX_DEPTH: usize = 400;
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
const LAMBDA_FUEL: u64 = MAX_CELLS;
/// Work one cell's formula may do: one unit per expression evaluated, per cell read from a
/// reference, per array value handed to a function, per pair of areas intersected and per
/// character of a defined name's formula parsed where the name is used. Each limit
/// above bounds one kind of step, but a compact formula could still repeat bounded steps
/// exponentially often (defined names that each use the one before twice, a computed criterion
/// whose database function reruns it, an aggregate over a large array inside every LAMBDA call).
/// Past this the formula is `#CALC!`. It is far above what a formula that reads every cell of a
/// full-size array a few times needs.
const EVAL_WORK: u64 = 16 * MAX_CELLS;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Area {
@@ -115,6 +124,12 @@ pub trait Host {
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
false
}
/// `(sum, count)` of the numbers in a block that holds nothing but constants (no formula,
/// error or spilled value), added row-major from `0.0` exactly as `visit_range` would hand
/// them over. `None`: the block isn't like that, or this host can't tell.
fn range_sum(&mut self, _sheet: usize, _range: RangeRef) -> Option<(f64, u64)> {
None
}
/// While a formula is calculated on demand inside another one: `stack_position()` where the
/// outermost one started, which the stack budget is counted from.
fn stack_top(&self) -> Option<usize> {
@@ -139,6 +154,16 @@ pub struct Evaluator<'h> {
/// runs out of work after the same number of calls that a legitimately wide one could
/// make, instead of an amount of work that squares with array nesting (#2382).
fuel: u64,
/// Work this cell still has left (see [`EVAL_WORK`]), shared by every branch of the formula
/// and by the formulas it computes on the way (names, computed criteria).
work: u64,
/// A spend found too little work left. A spend that leaves exactly nothing is fine, so this
/// can't be told from `work` alone; a database function checks it after its computed
/// criteria ran, whose failures it can't otherwise see.
exhausted: bool,
/// Computed criteria being evaluated, outermost first: one that is reached again from inside
/// itself counts as not met instead of repeating its work at every level.
active_criteria: Vec<(usize, CellRef)>,
}
struct FnCtx<'a, 'h> {
@@ -168,6 +193,9 @@ impl Ctx for FnCtx<'_, '_> {
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))?;
if self.ev.active_criteria.contains(&(area.sheet, at)) {
return Some(false);
}
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)
@@ -187,12 +215,24 @@ impl Ctx for FnCtx<'_, '_> {
calls: self.ev.calls,
stack_top: self.ev.stack_top,
fuel: self.ev.fuel,
work: self.ev.work,
exhausted: false,
active_criteria: std::mem::take(&mut self.ev.active_criteria),
};
ev.active_criteria.push((area.sheet, at));
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
// The child's spend counts against this cell's budget.
self.ev.fuel = ev.fuel;
self.ev.work = ev.work;
self.ev.exhausted |= ev.exhausted;
let mut active = std::mem::take(&mut ev.active_criteria);
active.pop();
self.ev.active_criteria = active;
Some(v)
}
fn exhausted(&self) -> bool {
self.ev.exhausted
}
fn has_lambda(&self) -> bool {
self.lambda.is_some()
}
@@ -210,7 +250,42 @@ impl Ctx for FnCtx<'_, '_> {
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
Evaluator {
host,
sheet,
at,
env: Vec::new(),
depth: 0,
calls: 0,
stack_top: 0,
fuel: LAMBDA_FUEL,
work: EVAL_WORK,
exhausted: false,
active_criteria: Vec::new(),
}
}
/// Spends `units` of this cell's work; `false` (and nothing left) when there isn't enough.
pub(crate) fn spend(&mut self, units: u64) -> bool {
match self.work.checked_sub(units) {
Some(w) => {
self.work = w;
true
}
None => {
self.work = 0;
self.exhausted = true;
false
}
}
}
/// Charges the values of an array (one unit per value; one for anything else).
pub(crate) fn spend_on(&mut self, v: &Value) -> bool {
match v {
Value::Array(a) => self.spend(a.data.len() as u64),
_ => self.spend(1),
}
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -260,18 +335,27 @@ impl<'h> Evaluator<'h> {
}
// A reference keeps its size, so the array has its shape and its blanks count
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
let range = self.read_range(a);
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
if !self.spend((h as u64) * (w as u64)) {
return Value::Error(CellError::Calc);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
}
/// The block that reading `a` gives: `a` itself, or when it is large (or runs to the sheet's
/// edge) the part inside the sheet's used range.
pub(crate) fn read_range(&mut self, a: &Area) -> RangeRef {
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
// the column", not for a block of a chosen size.
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
let range = if full_size { a.range } else { self.trim(a) };
let (h, w) = (range.height() as usize, range.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Value::Error(CellError::Num);
}
let data = self.host.range_values(a.sheet, range);
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
if full_size { a.range } else { self.trim(a) }
}
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
@@ -304,6 +388,9 @@ impl<'h> Evaluator<'h> {
self.host.stack_exhausted();
return err(CellError::Num);
}
if !self.spend(1) {
return err(CellError::Calc);
}
self.depth += 1;
if self.depth > MAX_DEPTH {
self.depth -= 1;
@@ -325,6 +412,11 @@ impl<'h> Evaluator<'h> {
Expr::Array(rows) => {
let h = rows.len();
let w = rows.first().map_or(0, Vec::len);
// A ragged literal is an error either way; one wide row and many empty ones must
// not reserve their rows-times-width product first.
if rows.iter().any(|row| row.len() != w) {
return err(CellError::Value);
}
let mut data = Vec::with_capacity(h * w);
for row in rows {
for el in row {
@@ -371,6 +463,11 @@ impl<'h> Evaluator<'h> {
if let Some(def) = wb.name(bare, scope_sheet) {
let text = def.formula.clone();
let scope = def.scope.unwrap_or(self.sheet);
// Each use parses and evaluates the definition again: names that each use the one
// before twice double the work per name, so it is charged.
if !self.spend(text.len() as u64) {
return err(CellError::Calc);
}
return match gridcraft_formula::parse_name(&text) {
Ok(expr) => {
// Relative references in a name are relative to A1: used here, they move by
@@ -532,6 +629,11 @@ impl<'h> Evaluator<'h> {
if r.height() == 1 && r.start.col <= self.at.col && self.at.col <= r.end.col {
return Some(CellRef::new(r.start.row, self.at.col));
}
// A 2-D range intersects on both the formula's row and column (observed in Excel, on
// another sheet too); one that includes the formula's own cell is a circular reference.
if r.start.row <= self.at.row && self.at.row <= r.end.row && r.start.col <= self.at.col && self.at.col <= r.end.col {
return Some(self.at);
}
None
}
@@ -563,6 +665,10 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(mut xa), Ev::R(ya)) => {
// A union reused through LET doubles at each step: its areas count.
if !self.spend((xa.len() as u64).saturating_add(ya.len() as u64)) {
return err(CellError::Calc);
}
xa.extend(ya);
Ev::R(xa)
}
@@ -574,6 +680,11 @@ impl<'h> Evaluator<'h> {
let (x, y) = (self.eval(a), self.eval(b));
match (x, y) {
(Ev::R(xa), Ev::R(ya)) => {
// Every pair is looked at, and each may be kept: charged before the loop,
// so unions squared through LET can't build an enormous list.
if !self.spend((xa.len() as u64).saturating_mul(ya.len() as u64)) {
return err(CellError::Calc);
}
let mut out = Vec::new();
for p in &xa {
for q in &ya {
@@ -667,6 +778,113 @@ mod fuel_tests {
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
}
/// Evaluates `src` in `wb` with `work` units of work, and the work left.
fn eval_work(wb: Workbook, work: u64, src: &str) -> (Value, u64) {
let mut host = Empty { wb };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
ev.work = work;
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, ev.work)
}
fn is_calc(v: &Value) -> bool {
matches!(v, Value::Error(CellError::Calc))
}
/// Serves the workbook's stored values, for database functions to read.
struct Stored {
wb: Workbook,
}
impl Host for Stored {
fn workbook(&self) -> &Workbook {
&self.wb
}
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
self.wb.sheet(sheet).and_then(|s| s.cell(c)).map_or(Value::Empty, |cell| cell.value.clone())
}
fn now_serial(&self) -> f64 {
0.0
}
fn random(&mut self) -> f64 {
0.0
}
}
/// A computed criterion that runs out of work counts as not met for its record, which the
/// database function can't see: it must answer #CALC!, not a count that leaves records out.
#[test]
fn a_computed_criterion_out_of_work_makes_the_database_function_calc() {
use gridcraft_model::{Cell, Formula};
let mut wb = Workbook::new();
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(name("Step0".into(), "1".into()));
for i in 1..=60 {
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
}
let at = |s: &str| CellRef::parse(s).unwrap();
let sheet = wb.sheet_mut(0).unwrap();
for (r, (n, age)) in [("name", Value::text("age")), ("a", Value::number(1.0)), ("b", Value::number(2.0))].into_iter().enumerate() {
sheet.cells.set(at(&format!("A{}", r + 1)), Cell::value(Value::text(n)));
sheet.cells.set(at(&format!("B{}", r + 1)), Cell::value(age));
}
sheet.cells.set(at("D1"), Cell::value(Value::text("Formula")));
let host_wb = |criterion: &str| {
let mut wb = wb.clone();
// A criteria cell is read by its value first (a blank one matches everything): give it
// the value a recalculation would have stored.
let mut cell = Cell::formula(Formula::new(criterion));
cell.value = Value::Bool(true);
wb.sheet_mut(0).unwrap().cells.set(at("D2"), cell);
wb
};
let run = |criterion: &str| {
let mut host = Stored { wb: host_wb(criterion) };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(9, 9));
ev.work = 100_000;
ev.value(&gridcraft_formula::parser::parse("=DCOUNT(A1:B3,\"age\",D1:D2)").unwrap())
};
assert_eq!(run("=B2>0"), Value::number(2.0), "a cheap criterion counts every record");
assert!(is_calc(&run("=Step60>0")), "{:?}", run("=Step60>0"));
}
#[test]
fn names_that_double_at_each_step_run_out_of_work() {
// Step1 = Step0+Step0, Step2 = Step1+Step1, ...: Step60 would evaluate Step0 2^60 times.
let mut wb = Workbook::default();
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(name("Step0".into(), "1".into()));
for i in 1..=60 {
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
}
let (v, _) = eval_work(wb.clone(), EVAL_WORK, "=Step5");
assert_eq!(v, Value::number(32.0));
let (v, left) = eval_work(wb, 100_000, "=Step60");
assert!(is_calc(&v), "{v:?}");
assert_eq!(left, 0);
}
#[test]
fn intersections_of_unions_are_charged_per_pair() {
let wb = Workbook::default();
// 4 x 4 pairs, 4 of which meet.
let src = "=AREAS((A1,B1,C1,D1) (A1,B1,C1,D1))";
let (v, _) = eval_work(wb.clone(), EVAL_WORK, src);
assert_eq!(v, Value::number(4.0));
let (v, _) = eval_work(wb, 20, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn an_aggregate_inside_every_lambda_call_spends_work() {
let wb = Workbook::default();
// 100 calls, each summing the 10,000 values of a captured array: about a million units.
let src = "=LET(big,SEQUENCE(10000),SUM(MAP(SEQUENCE(100),LAMBDA(x,SUM(big)))))";
let (v, _) = eval_work(wb.clone(), 2_000_000, src);
assert_eq!(v, Value::number(100.0 * 50_005_000.0));
let (v, _) = eval_work(wb, 200_000, src);
assert!(is_calc(&v), "{v:?}");
}
#[test]
fn lambda_calls_spend_fuel_and_run_out_quietly() {
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
+79 -1
View File
@@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
}
}
/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the
/// result grows as cells are made rather than being reserved for the whole broadcast up front.
fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option<Value>) -> Option<Value> {
Some(match (a, b) {
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
if broadcast_len(rows, cols).is_none() {
return Some(Value::Error(CellError::Num));
}
let mut data = Vec::new();
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?);
}
}
Value::from(Array { rows, cols, data })
}
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::<Option<_>>()? }),
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::<Option<_>>()? }),
(x, y) => f(x, y)?,
})
}
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
/// Each operand passes its own size cap, but their broadcast product can be far larger
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
@@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option<Value> {
}
}
/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a
/// compact formula can broadcast a long text across millions of cells
/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the
/// error for a result too large to hold, before the rest is built.
pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024;
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) {
return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES);
}
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget`
/// bytes.
fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value {
let mut used = 0usize;
try_zip2(x, y, |a, b| {
let v = binary_scalar(BinOp::Concat, a, b, sys);
if let Value::Text(t) = &v {
used = used.saturating_add(t.len());
if used > budget {
return None;
}
}
Some(v)
})
.unwrap_or(Value::Error(CellError::Num))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
@@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
#[cfg(test)]
mod tests {
use super::{MAX_CELLS, broadcast_len};
use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2};
/// The broadcast stops at the first refused cell: nothing after it is visited.
#[test]
fn a_refused_cell_stops_the_broadcast() {
let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect()));
let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() });
let mut calls = 0;
let r = try_zip2(&col, &row, |a, _| {
calls += 1;
(calls <= 10).then(|| a.clone())
});
assert_eq!((r, calls), (None, 11));
}
#[test]
fn concatenating_arrays_stops_at_the_byte_budget() {
let sys = gridcraft_core::DateSystem::default();
let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect()));
let long = Value::text("x".repeat(100));
// 100 cells of 101 or 102 bytes each: about 10 KB in all.
let r = concat_arrays(&col, &long, sys, 20_000);
assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}");
assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num));
assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num));
// Through the operator, a small result is unaffected.
let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys);
assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}");
}
#[test]
fn broadcast_len_caps_at_max_cells() {
+44 -8
View File
@@ -52,13 +52,16 @@ impl<'h> Evaluator<'h> {
"ISFORMULA" | "FORMULATEXT" => {
let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) };
let Some(a) = a.first() else { return err(CellError::NA) };
let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
let wb = self.host.workbook();
let f = wb.sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
// A formula formatted Hidden on a protected sheet stays hidden from FORMULATEXT too.
let hidden = wb.sheet(a.sheet).is_some_and(|s| s.is_protected() && wb.styles.get(s.style_id(a.range.start)).protection.hidden);
if name == "ISFORMULA" {
Ev::V(Value::Bool(f.is_some()))
} else {
match f {
Some(f) => Ev::V(Value::text(format!("={}", f.text))),
None => err(CellError::NA),
Some(f) if !hidden => Ev::V(Value::text(format!("={}", f.text))),
_ => err(CellError::NA),
}
}
}
@@ -203,9 +206,9 @@ impl<'h> Evaluator<'h> {
let index = self.value(&args[0]);
if let Value::Array(_) = index {
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
let mut evaluated = vec![Arg::val(index)];
for argument in &args[1..] {
evaluated.push(Arg { value: self.value(argument), from_ref: false });
evaluated.push(Arg::val(self.value(argument)));
}
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
@@ -351,10 +354,25 @@ impl<'h> Evaluator<'h> {
}
ev => self.deref(ev),
};
evaluated.push(Arg { value, from_ref });
// Only an argument left out (`f(a,,b)`) is omitted; one that computes a blank is given.
let blank_value = !from_ref && value == Value::Empty && lambda_at != Some(i) && !matches!(a, Expr::Missing);
evaluated.push(Arg { value, from_ref, blank_value });
}
// The function may go through every value it is given (a SUM inside each call of a
// LAMBDA over a large array): the values count against the cell's work.
let values = evaluated.iter().fold(0u64, |n, a| n.saturating_add(if let Value::Array(x) = &a.value { x.data.len() as u64 } else { 1 }));
if !self.spend(values) {
return err(CellError::Calc);
}
let database = criteria.is_some();
let mut ctx = FnCtx { ev: self, criteria, lambda };
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
let v = gridcraft_functions::call(spec, &evaluated, &mut ctx);
// A computed criterion that ran out of work counted as not met: the result leaves
// records out.
if database && self.exhausted {
return err(CellError::Calc);
}
Ev::V(v)
}
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
@@ -383,6 +401,21 @@ impl<'h> Evaluator<'h> {
/// Adds one area to `acc`; `Err` is the result the function ends with.
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
if !a.range.is_single() {
// Adding the block's remembered sum to anything but +0.0 would add in another order.
// COUNT never reads the sum, so it may reuse every block.
if (kind == Agg::Count || acc.sum.to_bits() == 0)
&& let Some((sum, count)) = self.host.range_sum(a.sheet, a.range)
{
acc.sum = sum;
acc.count = acc.count.saturating_add(count);
return Ok(());
}
// Folded without building an array, but still walked: the part inside the used
// range counts against the cell's work.
let r = self.read_range(a);
if !self.spend(u64::from(r.height()) * u64::from(r.width())) {
return Err(err(CellError::Calc));
}
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
@@ -402,10 +435,13 @@ impl<'h> Evaluator<'h> {
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
if !from_ref && !self.spend_on(&value) {
return Err(err(CellError::Calc));
}
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
let args = [Arg { value, from_ref }];
let args = [Arg { value, from_ref, blank_value: false }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
Value::Number(n) => acc.sum += n,
+103 -6
View File
@@ -118,11 +118,29 @@ impl<'h> Evaluator<'h> {
for c in 0..w {
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
let v = match pos {
// A position that picks several cells is #CALC!. That is decided from the
// shape: reading the block first, for every position, let a short formula
// read one large range over and over only to discard it.
(Ok(row), Ok(col)) if index_picks_several_values(&src, row, col, two) => Value::Error(CellError::Calc),
(Ok(row), Ok(col)) => {
let one = self.index_one(src.clone(), row, col, area_n, two);
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
let cells = match &one {
Ev::R(areas) => match areas.as_slice() {
[a] => self.area_cells(a),
_ => 0,
},
_ => 0,
};
if cells > MAX_CELLS {
// As reading it would answer.
Value::Error(CellError::Num)
} else if cells > 1 {
Value::Error(CellError::Calc)
} else {
match self.deref(one) {
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
v => v.scalar(),
}
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
@@ -139,6 +157,16 @@ impl<'h> Evaluator<'h> {
self.index_one(src, row, col, area_n, two)
}
/// Cells that reading `a` gives: its own size, or for a large block the part inside the
/// sheet's used range (what [`Evaluator::area_value`] reads).
fn area_cells(&mut self, a: &Area) -> u64 {
if a.range.is_single() {
return 1;
}
let r = self.read_range(a);
u64::from(r.height()) * u64::from(r.width())
}
/// INDEX for one row and column position (0 = the whole column or row).
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
if row < 0 || col < 0 {
@@ -203,7 +231,7 @@ impl<'h> Evaluator<'h> {
let horizontal = l.range.height() == 1 && l.range.width() > 1;
let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() };
if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len {
let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }];
let mut xargs = vec![Arg::val(key), Arg::reference(self.area_value(&l))];
// An empty match_mode before a given search_mode is the default (0), not the end.
let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing));
let last = if given(5) {
@@ -215,10 +243,11 @@ impl<'h> Evaluator<'h> {
};
for i in 4..=last {
match args.get(i) {
Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }),
Some(Expr::Missing) | None => xargs.push(Arg::val(Value::Number(0.0))),
Some(e) => {
let value = self.value(e);
xargs.push(Arg { value, from_ref: false });
let blank_value = value == Value::Empty;
xargs.push(Arg { value, from_ref: false, blank_value });
}
}
}
@@ -407,3 +436,71 @@ impl<'h> Evaluator<'h> {
Ev::V(gridcraft_functions::aggregate_values(func, &values))
}
}
/// Whether INDEX on an array picks a whole row or column of several values at position
/// (`row`, `col`), which the array-of-positions form answers with #CALC!.
fn index_picks_several_values(src: &Ev, row: i64, col: i64, two_args: bool) -> bool {
let Ev::V(Value::Array(arr)) = src else { return false };
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
if row < 0 || col < 0 || row as u64 > arr.rows as u64 || col as u64 > arr.cols as u64 {
return false;
}
(row == 0 && arr.rows > 1) || (col == 0 && arr.cols > 1)
}
#[cfg(test)]
mod tests {
use super::super::Host;
use super::*;
use gridcraft_model::Workbook;
/// A sheet of blanks that counts the blocks it is asked for.
struct Counting {
wb: Workbook,
cells_read: usize,
}
impl Host for Counting {
fn workbook(&self) -> &Workbook {
&self.wb
}
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
Value::Empty
}
fn now_serial(&self) -> f64 {
0.0
}
fn random(&mut self) -> f64 {
0.0
}
fn range_values(&mut self, _sheet: usize, range: RangeRef) -> Vec<Value> {
self.cells_read += range.count() as usize;
vec![Value::Empty; range.count() as usize]
}
}
fn eval(src: &str) -> (Value, usize) {
let mut host = Counting { wb: Workbook::default(), cells_read: 0 };
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
(v, host.cells_read)
}
fn all_calc(v: &Value, n: usize) -> bool {
matches!(v, Value::Array(a) if a.data.len() == n && a.data.iter().all(|x| *x == Value::Error(CellError::Calc)))
}
#[test]
fn index_positions_that_pick_several_cells_are_calc_without_reading_them() {
// Each zero row position picks the whole of A1:A50000: #CALC!, decided from the shape. It
// used to read the 50,000 cells once per position before discarding them.
let (v, read) = eval("=INDEX(A1:A50000,{0,0,0,0},1)");
assert!(all_calc(&v, 4), "{v:?}");
assert_eq!(read, 0);
// The same for an array source: a whole column of a 3x3 array.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{0,0},2)");
assert!(all_calc(&v, 2), "{v:?}");
// Single cells are still read.
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{1,3},2)");
assert!(matches!(&v, Value::Array(a) if a.data == vec![Value::number(2.0), Value::number(8.0)]), "{v:?}");
}
}
+3
View File
@@ -4,10 +4,13 @@
#![forbid(unsafe_code)]
pub mod eval;
mod precision;
pub mod recalc;
mod spill_index;
mod trimrange;
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
pub use precision::DisplayedPrecision;
pub use recalc::{Calc, Key, evaluate, now_serial};
#[cfg(test)]
+52
View File
@@ -0,0 +1,52 @@
//! "Set precision as displayed" (File › Options › Advanced; `fullPrecision="0"` in `.xlsx`,
//! CalcPrecision in `.xls`): with it on, a number is stored as its cell's number format shows it,
//! and formulas read those rounded values. The rounding itself is the number format crate's
//! ([`gridcraft_numfmt::NumberFormat::round_as_displayed`]); this module looks up each cell's
//! format, parsing every style's code once.
use std::collections::HashMap;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{StyleId, Workbook};
use gridcraft_numfmt::NumberFormat;
/// Rounds values to their cell's displayed precision, remembering each style's parsed format.
/// Styles don't change while it is used (one recalculation pass, one sweep of a workbook).
#[derive(Default)]
pub struct DisplayedPrecision {
/// Parsed format per style; `None` for General, which keeps the full value.
formats: HashMap<StyleId, Option<NumberFormat>>,
}
impl DisplayedPrecision {
pub fn new() -> Self {
Self::default()
}
/// `n` as the format of `c` on sheet `sheet` displays it (the cell's own style, else its
/// row's, else its column's, as the grid shows it).
pub fn round(&mut self, wb: &Workbook, sheet: usize, c: CellRef, n: f64) -> f64 {
let Some(sh) = wb.sheet(sheet) else { return n };
let id = sh.style_id(c);
let fmt = self.formats.entry(id).or_insert_with(|| {
let f = NumberFormat::parse_cached(wb.styles.get(id).num_fmt.as_str());
(!f.is_general()).then_some(f)
});
match fmt {
Some(f) => f.round_as_displayed(n),
None => n,
}
}
/// Like [`DisplayedPrecision::round`] for a value: only numbers change.
pub fn round_value(&mut self, wb: &Workbook, sheet: usize, c: CellRef, v: Value) -> Value {
match v {
Value::Number(n) => {
let r = self.round(wb, sheet, c, n);
// Bit-equal results keep the value as it was (no needless change for undo/diffs).
if r.to_bits() == n.to_bits() { Value::Number(n) } else { Value::Number(r) }
}
v => v,
}
}
}
+359 -47
View File
@@ -10,13 +10,24 @@
//! and the formulas that read one are evaluated again until the loop settles
//! (`PassHost::iterate_loops`).
use std::collections::VecDeque;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{CalcMode, Workbook};
use crate::eval::{Evaluator, Host, precedents, stack_position};
use crate::spill_index::{SpillIndex, indexing_pays};
/// Changed cells (in one `cells_changed`) from which a sheet's spill areas may be indexed.
const INDEX_SPILLS_FROM: usize = 16;
/// Whether a sheet's spill areas are indexed for a batch of `batch` changed cells of which
/// `on_sheet` are on that sheet: the batch must be a paste, and registering the areas must cost
/// less than walking them once for each of the sheet's own changed cells.
fn index_spills<'a>(batch: usize, on_sheet: usize, areas: impl ExactSizeIterator<Item = &'a RangeRef>) -> bool {
batch > INDEX_SPILLS_FROM && areas.len() > 0 && indexing_pays(areas, on_sheet)
}
mod graph;
mod pass_host;
@@ -24,7 +35,7 @@ mod pass_host;
#[cfg(test)]
use graph::Intervals;
pub use graph::{FxHasher, Graph, Key};
use graph::{HashMap, HashSet, Node};
use graph::{HashMap, HashSet, Node, Taken};
use pass_host::{PassHost, spills_as_before};
/// Calculation state kept beside a workbook.
@@ -37,11 +48,61 @@ pub struct Calc {
/// Cells whose formulas have errors from cycles.
pub circular: Vec<Key>,
pub last_recalc_cells: usize,
/// The cells whose value is `#SPILL!`, kept so that finding the formulas an emptied cell or a
/// freed spill area may unblock doesn't walk every formula or window of cells. Filled in
/// `rebuild`, kept up to date by `cells_changed` and by the write-back of a run; an entry is
/// always checked against the workbook before it is used.
spill_errors: SpillErrors,
}
/// A set of cells by row, each row's columns in their own set: a window of rows and columns is
/// found by one column range per row that has an entry, so the cost follows the cells in the
/// window, not those to the side of it in the same rows.
#[derive(Clone, Debug, Default)]
struct SpillErrors {
rows: BTreeMap<(usize, u32), BTreeSet<u32>>,
}
impl SpillErrors {
fn is_empty(&self) -> bool {
self.rows.is_empty()
}
fn clear(&mut self) {
self.rows.clear();
}
fn insert(&mut self, (sheet, c): Key) {
self.rows.entry((sheet, c.row)).or_default().insert(c.col);
}
fn remove(&mut self, (sheet, c): &Key) {
if let Some(cols) = self.rows.get_mut(&(*sheet, c.row)) {
cols.remove(&c.col);
if cols.is_empty() {
self.rows.remove(&(*sheet, c.row));
}
}
}
/// The cells of `sheet` in `rows` x `cols` (both inclusive), in row-major order.
fn range(&self, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> impl Iterator<Item = CellRef> + '_ {
self.rows
.range((sheet, rows.0)..=(sheet, rows.1))
.flat_map(move |(&(_, row), set)| set.range(cols.0..=cols.1).map(move |&col| CellRef::new(row, col)))
}
}
impl Default for Calc {
fn default() -> Self {
Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 }
Calc {
graph: Graph::default(),
rng: 0x9E37_79B9_7F4A_7C15,
fixed_now: None,
circular: vec![],
last_recalc_cells: 0,
spill_errors: SpillErrors::default(),
}
}
}
@@ -125,12 +186,15 @@ impl Calc {
pub fn rebuild(&mut self, wb: &Workbook) {
self.graph = Graph::default();
let mut circ = Vec::new();
self.spill_errors.clear();
for (si, sheet) in wb.sheets.iter().enumerate() {
for (c, cell) in sheet.cells.iter() {
if cell.value == Value::Error(CellError::Spill) {
self.spill_errors.insert((si, c));
}
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
&& let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e))
{
let (areas, dynamic) = precedents(wb, si, &e);
self.graph.insert((si, c), Node { areas, dynamic });
if cell.value == Value::Error(CellError::Circ) {
circ.push((si, c));
@@ -188,6 +252,20 @@ impl Calc {
out
}
/// The cells of `sheet` in `rows` x `cols` (inclusive, `cols` a lower and an upper column) that
/// show `#SPILL!`, in row-major order.
fn spill_errors_in(&self, wb: &Workbook, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> Vec<CellRef> {
let Some(sh) = wb.sheet(sheet) else { return Vec::new() };
self.spill_errors.range(sheet, rows, cols).filter(|c| sh.cells.get(*c).is_some_and(|x| x.value == Value::Error(CellError::Spill))).collect()
}
/// The formulas showing `#SPILL!` on `sheet` at or above and to the left of `end`: what a
/// cell emptied or a spill area freed there may unblock.
fn unblock_candidates(&self, wb: &Workbook, sheet: usize, end: CellRef) -> Vec<Key> {
let cells = self.spill_errors_in(wb, sheet, (0, end.row), (0, end.col));
cells.into_iter().filter(|c| self.graph.nodes.contains_key(&(sheet, *c))).map(|c| (sheet, c)).collect()
}
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
pub fn recalc_all(&mut self, wb: &mut Workbook) {
self.rebuild(wb);
@@ -209,14 +287,19 @@ impl Calc {
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
if wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.value == Value::Error(CellError::Spill)) {
self.spill_errors.insert(k);
} else {
self.spill_errors.remove(&k);
}
let precedent =
wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e)));
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
let python = py.is_some();
// A Python cell whose result (if any) is not an array spills nothing.
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
match formula.and_then(|f| f.expr()) {
Some(e) => {
let (areas, dynamic) = precedents(wb, k.0, &e);
match precedent {
Some((areas, dynamic)) => {
self.graph.insert(k, Node { areas, dynamic });
}
// See `rebuild`: its spill is laid out again from its result when it is calculated.
@@ -249,6 +332,7 @@ impl Calc {
&& cell.formula.is_some()
{
cell.value = Value::Error(CellError::Name);
self.spill_errors.remove(&k);
}
}
}
@@ -264,17 +348,40 @@ impl Calc {
let mut emptied: Vec<Key> = Vec::new();
// Typing into (or clearing) a cell a dynamic array spills over (or would spill over)
// re-evaluates the anchor, which may now be blocked or unblocked.
// With many changed cells (a paste) a sheet's spill areas are indexed once instead of being
// walked for each one, when registering them costs less than walking them would.
let mut areas = SpillIndex::default();
let mut strategy: HashMap<usize, bool> = HashMap::default();
let mut per_sheet: HashMap<usize, usize> = HashMap::default();
for k in changed {
*per_sheet.entry(k.0).or_insert(0) += 1;
}
let mut at = Vec::new();
for &k in changed {
if let Some(sh) = wb.sheet(k.0) {
for (anchor, r) in &sh.spill_ranges {
if *anchor != k.1 && r.contains(k.1) {
seeds.push((k.0, *anchor));
let indexed = *strategy.entry(k.0).or_insert_with(|| {
let indexed = index_spills(changed.len(), per_sheet[&k.0], sh.spill_ranges.values());
if indexed {
for (anchor, r) in &sh.spill_ranges {
areas.insert_rect((k.0, *anchor), *r, false);
}
}
indexed
});
if indexed {
// Anchor order, as walking `spill_ranges` gives.
areas.meeting(k.0, RangeRef::new(k.1, k.1), &mut at);
seeds.extend(at.iter().filter(|a| **a != k.1).map(|a| (k.0, *a)));
} else {
for (anchor, r) in &sh.spill_ranges {
if *anchor != k.1 && r.contains(k.1) {
seeds.push((k.0, *anchor));
}
}
}
for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) {
if c != k.1 && cell.value == Value::Error(CellError::Spill) {
seeds.push((k.0, c));
}
if !self.spill_errors.is_empty() {
let (r0, c0) = (k.1.row.saturating_sub(64), k.1.col.saturating_sub(64));
seeds.extend(self.spill_errors_in(wb, k.0, (r0, k.1.row), (c0, k.1.col)).into_iter().filter(|c| *c != k.1).map(|c| (k.0, c)));
}
if !sh.cells.has(k.1) {
match emptied.iter_mut().find(|x| x.0 == k.0) {
@@ -286,23 +393,18 @@ impl Calc {
}
// An emptied cell can unblock an anchor any distance above or to the left of it.
for (si, end) in emptied {
for &(s, c) in self.graph.nodes.keys() {
if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {
seeds.push((s, c));
}
}
seeds.extend(self.unblock_candidates(wb, si, end));
}
// Spill areas of changed anchors also change.
// Spill areas of changed anchors also change. They stay rectangles: one seed per spilled
// cell listed the formulas reading the area once per cell.
let mut areas: Vec<(usize, RangeRef)> = cleared;
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().map(|c| (k.0, c)));
areas.push((k.0, *r));
}
}
for (si, r) in &cleared {
seeds.extend(r.iter().map(|c| (*si, c)));
}
let t0 = prof_now();
let dirty = self.dirty_closure(wb, &seeds);
let dirty = self.dirty_closure(wb, &seeds, &areas);
let t1 = prof_now();
let n = dirty.len();
self.run(wb, dirty, false);
@@ -311,16 +413,39 @@ impl Calc {
}
}
fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec<Key> {
/// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds
/// that are formulas, and everything that reads them, directly or through other formulas.
fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec<Key> {
let mut seen: HashSet<Key> = HashSet::default();
let mut queue: VecDeque<Key> = VecDeque::new();
// Each range-index entry is listed once in the whole walk: many changed cells under one
// broad reference (or many formulas inside one) would otherwise list every formula reading
// it once per cell, which squared the work.
let mut taken = Taken::default();
let mut deps = Vec::new();
for k in seeds {
if self.graph.nodes.contains_key(k) && seen.insert(*k) {
queue.push_back(*k);
}
let mut deps = Vec::new();
self.graph.dependents(k.0, k.1, &mut deps);
for d in deps {
deps.clear();
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
}
}
}
for &(si, r) in areas {
if let Some(sh) = wb.sheet(si) {
for (c, _) in sh.cells.iter_range(r) {
if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) {
queue.push_back((si, c));
}
}
}
deps.clear();
self.graph.dependents_of_range(si, r, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
}
@@ -331,10 +456,9 @@ impl Calc {
queue.push_back(*k);
}
}
let mut deps = Vec::new();
while let Some(k) = queue.pop_front() {
deps.clear();
self.graph.dependents(k.0, k.1, &mut deps);
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
for d in deps.drain(..) {
if seen.insert(d) {
queue.push_back(d);
@@ -411,7 +535,7 @@ impl Calc {
if spill_changes.is_empty() {
break;
}
dirty = self.dirty_closure(wb, &spill_changes);
dirty = self.dirty_closure(wb, &spill_changes, &[]);
}
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
// Iterative calculation reports none.
@@ -433,10 +557,15 @@ impl Calc {
let mut freed = Vec::new();
for (key, value) in results {
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
if let Some(cell) = sheet.cells.get_mut(key.1)
&& cell.value != value
{
cell.value = value;
if let Some(cell) = sheet.cells.get_mut(key.1) {
if value == Value::Error(CellError::Spill) {
self.spill_errors.insert(key);
} else {
self.spill_errors.remove(&key);
}
if cell.value != value {
cell.value = value;
}
}
}
for key in dirty {
@@ -466,6 +595,7 @@ impl Calc {
if blocked {
if let Some(cell) = sheet.cells.get_mut(key.1) {
cell.value = Value::Error(CellError::Spill);
self.spill_errors.insert(*key);
}
let mut blocked_dependents = Vec::new();
self.graph.dependents_of_range(key.0, range, &mut blocked_dependents);
@@ -492,14 +622,7 @@ impl Calc {
}
}
for (sheet_index, range) in freed {
for &(sheet, cell) in self.graph.nodes.keys() {
let was_blocked_cell = cell.row <= range.end.row
&& cell.col <= range.end.col
&& wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill));
if sheet == sheet_index && was_blocked_cell {
spill_changes.push((sheet, cell));
}
}
spill_changes.extend(self.unblock_candidates(wb, sheet_index, range.end));
}
spill_changes
}
@@ -556,7 +679,7 @@ pub(crate) mod tests {
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
use super::{Calc, HashMap, Host, Intervals, Key, PassHost, index_spills};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
@@ -573,6 +696,19 @@ pub(crate) mod tests {
v
}
/// One changed cell on a sheet with many small spills does not index them just because the
/// batch (mostly on another sheet) is large; the sheet's own share of the batch decides.
#[test]
fn the_spill_index_is_chosen_from_the_sheets_own_changed_cells() {
let small: Vec<RangeRef> = (0..1_000).map(|i| RangeRef::new(CellRef::new(i * 3, 0), CellRef::new(i * 3 + 2, 0))).collect();
// Same areas, same batch of 5000: indexed when 5000 of them are on the sheet, walked when 1 is.
assert!(index_spills(5_000, 5_000, small.iter()));
assert!(!index_spills(5_000, 1, small.iter()));
// Not a paste, or nothing to index: never.
assert!(!index_spills(16, 16, small.iter()));
assert!(!index_spills(5_000, 5_000, [].iter()));
}
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
@@ -615,6 +751,39 @@ pub(crate) mod tests {
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
}
/// A Python array laid out in this pass is seen by the indexed readers, whether the sheet
/// has no spill from before or an older, smaller one.
#[test]
fn a_python_spill_found_in_the_pass_is_read_through_the_index() {
for stale in [false, true] {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
let anchor = CellRef::new(0, 0);
let py = gridcraft_model::PyCode::new("grid");
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
if stale {
// The last calculation spilled a 2 x 2 block of 100s.
sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(1, 1)));
for c in RangeRef::new(anchor, CellRef::new(1, 1)).iter().skip(1) {
sh.spill.insert(c, Value::Number(100.0));
}
}
let data: Vec<Value> = (1..=9).map(|i| Value::Number(f64::from(i))).collect();
let array = gridcraft_core::Array::new(3, 3, data).unwrap();
let mut python = HashMap::default();
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
let exprs = HashMap::default();
let mut rng = 1u64;
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
assert_eq!(host.cell_value(0, anchor), Value::Number(1.0));
let all = RangeRef::parse("A1:C3").unwrap();
let sum: f64 = host.range_values(0, all).iter().map(|v| if let Value::Number(n) = v { *n } else { 0.0 }).sum();
assert_eq!(sum, 45.0, "stale={stale}: a SUM over the new 3 x 3 spill reads all of it");
assert_eq!(host.cell_value(0, CellRef::new(2, 2)), Value::Number(9.0), "stale={stale}");
assert!(!host.visit_range(0, all, &mut |_| true), "stale={stale}: the new spill leaves the block to range_values");
}
}
#[test]
fn intervals_match_a_linear_scan() {
let mut iv: Intervals<u32> = Intervals::default();
@@ -691,6 +860,149 @@ pub(crate) mod tests {
assert!(visited < 64, "visited {visited} nodes for no match");
}
#[test]
fn untaken_queries_list_each_interval_once_in_all() {
// 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000
// each time (5 million matches); sharing `taken`, each range is listed once in all.
let n = 5_000u32;
let mut iv: Intervals<u32> = Intervals::default();
for t in 0..1_000 {
iv.insert(0, n, t);
}
for r in 0..n {
iv.insert(r, r, 10_000 + r);
}
iv.settle();
let mut taken = Vec::new();
let mut listed = Vec::new();
for row in 0..=n + 3 {
iv.query_untaken(row, row, &mut taken, &mut |t| {
listed.push(*t);
true
});
}
listed.sort_unstable();
let want: Vec<u32> = (0..1_000).chain(10_000..10_000 + n).collect();
assert_eq!(listed, want);
// An entry the caller declines is offered again.
let mut taken = Vec::new();
let mut offered = 0;
for _ in 0..3 {
iv.query_untaken(7, 7, &mut taken, &mut |t| {
if *t == 10_007 {
offered += 1;
}
*t != 10_007
});
}
assert_eq!(offered, 3);
}
#[test]
fn dirty_closure_matches_a_walk_of_every_cell() {
// Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some
// removed or left unsettled: the closure equals the one found by listing every dependent
// of every changed cell, area cells included, as before `taken` and areas existed.
use super::{HashSet, Node};
use crate::eval::Area;
use std::collections::VecDeque;
let mut s = 0x2545_F491_4F6C_DD1D;
for case in 0..40 {
let mut wb = Workbook::new();
let mut calc = Calc::new();
let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32;
let mut keys: Vec<Key> = Vec::new();
for _ in 0..300 {
let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150)));
let mut areas = Vec::new();
for _ in 0..1 + pick(&mut s, 3) {
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
let end = match pick(&mut s, 3) {
0 => start,
1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)),
_ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)),
};
areas.push(Area { sheet: 0, range: RangeRef::new(start, end) });
}
if let Some(sh) = wb.sheet_mut(0) {
sh.cells.set(k.1, Cell::formula(Formula::new("=1")));
}
calc.graph.insert(k, Node { areas, dynamic: false });
keys.push(k);
if rng(&mut s).is_multiple_of(7) {
calc.graph.settle();
}
}
for _ in 0..30 {
let k = keys[(rng(&mut s) % keys.len() as u64) as usize];
calc.graph.remove(k);
if let Some(sh) = wb.sheet_mut(0) {
sh.cells.remove(k.1);
}
}
if case % 2 == 0 {
calc.graph.settle();
}
let seeds: Vec<Key> = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect();
let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3))
.map(|_| {
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
(0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80))))
})
.collect();
let got: HashSet<Key> = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect();
// The walk before: every area cell is a seed, and every cell lists all its dependents.
let mut all = seeds.clone();
for (si, r) in &areas {
all.extend(r.iter().map(|c| (*si, c)));
}
let mut want: HashSet<Key> = HashSet::default();
let mut queue: VecDeque<Key> = VecDeque::new();
let mut deps = Vec::new();
for k in &all {
if calc.graph.nodes.contains_key(k) && want.insert(*k) {
queue.push_back(*k);
}
deps.clear();
calc.graph.dependents(k.0, k.1, &mut deps);
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
}
while let Some(k) = queue.pop_front() {
deps.clear();
calc.graph.dependents(k.0, k.1, &mut deps);
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
}
assert_eq!(got, want, "case {case}");
}
}
#[test]
fn clearing_a_spill_recalculates_formulas_reading_it_once_each() {
// 300 formulas read the area an anchor spills into; clearing the anchor changes all of it
// and every one of them is recalculated (they were listed once per spilled cell).
let mut wb = Workbook::new();
let mut calc = Calc::new();
let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| {
if let Some(sh) = wb.sheet_mut(0) {
if input.is_empty() {
sh.cells.remove(at);
} else {
sh.cells.set(at, Cell::formula(Formula::new(input)));
}
}
calc.cells_changed(wb, &[(0, at)]);
};
set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)");
for r in 0..300 {
set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)");
}
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0)));
set(&mut wb, &mut calc, CellRef::new(0, 0), "");
for r in 0..300 {
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}");
}
}
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
fn sum_rows(rows: u32) -> Workbook {
let mut wb = Workbook::new();
+81
View File
@@ -151,6 +151,54 @@ impl<T: Copy + Ord> Intervals<T> {
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
/// [`Intervals::query`] that skips entries an earlier call with the same `taken` already
/// took (and whole subtrees of them), so that a series of queries lists each interval once in
/// all. `f` answers whether it took the entry; one it declines is offered again later.
/// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at
/// most after [`Intervals::settle`]) are offered every time.
pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec<bool>, f: &mut dyn FnMut(&T) -> bool) {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return;
}
if taken.len() != self.max_end.len() {
*taken = vec![false; self.max_end.len()];
}
self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f);
}
/// Returns whether everything under `node` has now been reported (or is dead).
#[allow(clippy::too_many_arguments)]
fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool {
if taken.get(node).copied().unwrap_or(true) {
return true;
}
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return false;
}
let done = if hi - lo <= 1 {
match (self.items.get(lo), self.dead.get(lo)) {
(Some(it), Some(false)) if it.1 >= a => f(&it.2),
(Some(_), Some(true)) => true,
_ => false,
}
} else {
let mid = lo + (hi - lo) / 2;
let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f);
let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f);
left && right
};
if done && let Some(slot) = taken.get_mut(node) {
*slot = true;
}
done
}
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return 1;
@@ -168,6 +216,13 @@ impl<T: Copy + Ord> Intervals<T> {
}
}
/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]).
#[derive(Default)]
pub(super) struct Taken {
cols: HashMap<(usize, u32), Vec<bool>>,
wide: HashMap<usize, Vec<bool>>,
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
pub(super) nodes: HashMap<Key, Node>,
@@ -302,6 +357,32 @@ impl Graph {
}
}
/// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the
/// same `taken` already listed. A walk over many changed cells that a few broad ranges cover
/// then lists those formulas once, instead of once per cell (which squared the work). The
/// graph must not change while `taken` is in use.
pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec<Key>) {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| {
out.push(*k);
true
});
}
if let Some(v) = self.wide_deps.get(&sheet) {
// A wide entry is taken once it covers the cell's column too.
v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| {
let hit = *c0 <= c.col && c.col <= *c1;
if hit {
out.push(*k);
}
hit
});
}
}
/// Formulas that read any cell of `range` (a formula can be listed more than once).
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
if range.count() <= 4096 {
+164 -57
View File
@@ -1,5 +1,6 @@
use super::graph::{HashMap, HashSet, Key};
use super::{Evaluator, Host, Workbook, stack_position};
use crate::spill_index::{Cover, SpillIndex};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
@@ -13,6 +14,9 @@ const MAX_NESTED: usize = 64;
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
/// A block's results by last row: its sum and count, or `None` when it can't be summed from constants.
type BlockSums = std::collections::BTreeMap<u32, Option<(f64, u64)>>;
pub(super) struct PassHost<'a> {
wb: &'a Workbook,
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
@@ -21,6 +25,8 @@ pub(super) struct PassHost<'a> {
in_progress: HashSet<Key>,
/// New spills found during the pass: anchor → array.
pub(super) spills: HashMap<Key, std::sync::Arc<Array>>,
/// Where the areas in `spills` are, so a read finds the ones it meets without scanning them all.
spill_index: SpillIndex,
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
/// every row, so once per sheet rather than once per range read.
used: HashMap<usize, Option<RangeRef>>,
@@ -64,10 +70,17 @@ pub(super) struct PassHost<'a> {
pub(super) tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
/// "Set precision as displayed" is on: results are rounded to their cells' formats before
/// they are stored or read by other formulas.
precision: Option<crate::DisplayedPrecision>,
/// `stack_position()` where the outermost formula being calculated started.
stack_top: usize,
/// Whether an evaluation ran past the stack budget since the innermost formula started.
out_of_stack: bool,
/// Sum and count of blocks that hold only constants, for `range_sum`. Keyed by sheet, first
/// cell and last column; each value maps a last row to the block's result (`None`: the block
/// has a formula, a Python cell, an error value or a spilled value, so every longer one has too).
sums: HashMap<(usize, CellRef, u32), BlockSums>,
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
@@ -83,6 +96,7 @@ impl<'a> PassHost<'a> {
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
spill_index: SpillIndex::default(),
used: HashMap::default(),
rng,
now,
@@ -104,8 +118,10 @@ impl<'a> PassHost<'a> {
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
stack_top: 0,
out_of_stack: false,
sums: HashMap::default(),
python: HashMap::default(),
}
}
@@ -119,6 +135,30 @@ impl<'a> PassHost<'a> {
}
}
/// Most spill areas a range read meets before they are checked for overlapping one another by
/// walking all of the pass's spills instead of comparing pairs.
const MAX_UNORDERED_HITS: usize = 16;
/// Whether an array at `anchor` covers `c`.
fn spill_covers(anchor: CellRef, arr: &Array, c: CellRef) -> bool {
c.row >= anchor.row && c.col >= anchor.col && ((c.row - anchor.row) as usize) < arr.rows && ((c.col - anchor.col) as usize) < arr.cols
}
/// Whether two of the spill areas anchored at `hits` share a cell inside `range`.
fn spill_hits_overlap(index: &SpillIndex, sheet: usize, hits: &[CellRef], range: RangeRef) -> bool {
let rects: Vec<RangeRef> = hits.iter().filter_map(|a| index.area(sheet, *a)).filter(|(_, empty)| !empty).map(|(r, _)| r).collect();
rects.iter().enumerate().any(|(i, a)| {
rects.iter().skip(i + 1).any(|b| {
a.intersects(b)
&& RangeRef::new(
CellRef::new(a.start.row.max(b.start.row), a.start.col.max(b.start.col)),
CellRef::new(a.end.row.min(b.end.row), a.end.col.min(b.end.col)),
)
.intersects(&range)
})
})
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is
/// recalculated in this pass, and so may spill elsewhere now).
@@ -157,13 +197,6 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
}
/// The area an array anchored at `anchor` covers.
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
let end =
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
RangeRef::new(anchor, end)
}
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
@@ -344,20 +377,43 @@ impl PassHost<'_> {
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
};
// Precision as displayed: each element takes the format of the cell it lands in.
let a = match self.precision.as_mut() {
Some(p) if a.data.iter().any(|x| matches!(x, Value::Number(_))) => {
let cols = a.cols.max(1);
let data = a
.data
.iter()
.enumerate()
.map(|(i, x)| {
let at = CellRef::new(k.1.row.saturating_add((i / cols) as u32), k.1.col.saturating_add((i % cols) as u32));
p.round_value(self.wb, k.0, at, x.clone())
})
.collect();
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
}
_ => a,
};
// A dynamic-array formula anchors a spill range even while its result is one
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
// returns a scalar never reaches this arm, so it does not become an anchor.
self.spill_index.insert(k, a.rows, a.cols);
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
}
Value::Empty => Value::Number(0.0),
v @ Value::Number(_) => match self.precision.as_mut() {
Some(p) => p.round_value(self.wb, k.0, k.1, v),
None => v,
},
v => v,
};
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
self.spill_index.remove(k);
Value::Error(CellError::Spill)
}
_ => v,
@@ -377,6 +433,7 @@ impl PassHost<'_> {
&& a.cols >= 1
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
if sane {
self.spill_index.insert(k, a.rows, a.cols);
self.spills.insert(k, a.clone());
a.data.first().cloned().unwrap_or_default()
} else {
@@ -388,6 +445,7 @@ impl PassHost<'_> {
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
self.spill_index.remove(k);
Value::Error(CellError::Spill)
}
_ => v,
@@ -434,7 +492,11 @@ impl PassHost<'_> {
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
self.pending = set.clone();
self.spills.retain(|a, _| !set.contains(a));
let gone: Vec<Key> = self.spills.keys().filter(|a| set.contains(a)).copied().collect();
for a in gone {
self.spills.remove(&a);
self.spill_index.remove(a);
}
for k in &again {
self.calculate(*k);
}
@@ -518,17 +580,19 @@ impl Host for PassHost<'_> {
if self.pending.contains(&k) || self.results.contains_key(&k) {
return self.compute(k);
}
// A cell covered by a spill computed in this pass.
for (anchor, arr) in &self.spills {
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
if dr < arr.rows && dc < arr.cols {
if self.iterate && self.tainted.contains(anchor) {
self.taint = true;
}
return arr.get(dr, dc).cloned().unwrap_or_default();
}
// A cell covered by a spill computed in this pass. Where several overlap, the first in
// `spills`' iteration order counts, as it always has.
let covering = match self.spill_index.covering(sheet, c) {
Cover::None => None,
Cover::One(a) => self.spills.get_key_value(&(sheet, a)),
Cover::Many => self.spills.iter().find(|(a, arr)| a.0 == sheet && spill_covers(a.1, arr, c)),
};
if let Some((anchor, arr)) = covering {
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
if self.iterate && self.tainted.contains(anchor) {
self.taint = true;
}
return arr.get(dr, dc).cloned().unwrap_or_default();
}
self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default()
}
@@ -567,46 +631,44 @@ impl Host for PassHost<'_> {
}
}
if !self.spills.is_empty() {
// Only the part of each array inside the range is walked: a large array beside a small
// read costs nothing.
let mut new: Vec<(usize, Value)> = Vec::new();
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
let area = array_area(a.1, arr);
if !area.intersects(&range) {
continue;
}
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
for row in r0..=r1 {
for col in c0..=c1 {
let c = CellRef::new(row, col);
if c == a.1 {
continue;
}
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
}
}
}
#[cfg(test)]
{
self.spill_visits += new.len();
}
let mut hits = Vec::new();
self.spill_index.meeting(sheet, range, &mut hits);
// Iterating: an array spilled by a formula that read a previous value.
if self.iterate
&& self.spills.iter().any(|(a, arr)| {
let end = CellRef::new(
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
})
{
if self.iterate && hits.iter().any(|a| self.tainted.contains(&(sheet, *a))) {
self.taint = true;
}
for (i, v) in new {
if let Some(slot) = out.get_mut(i) {
*slot = v;
// Where areas overlap inside the range, the last in `spills`' iteration order wins,
// as it always has; otherwise the order doesn't matter.
let ordered = hits.len() > MAX_UNORDERED_HITS || spill_hits_overlap(&self.spill_index, sheet, &hits, range);
let mut put = |anchor: CellRef, arr: &Array| {
if arr.rows == 0 || arr.cols == 0 {
return;
}
let (r0, c0) = (range.start.row.max(anchor.row), range.start.col.max(anchor.col));
let r1 = range.end.row.min(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)));
let c1 = range.end.col.min(anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
for r in r0..=r1 {
for cc in c0..=c1 {
if (r, cc) != (anchor.row, anchor.col)
&& let Some(slot) = out.get_mut((r - range.start.row) as usize * w + (cc - range.start.col) as usize)
{
*slot = arr.get((r - anchor.row) as usize, (cc - anchor.col) as usize).cloned().unwrap_or_default();
}
}
}
};
if ordered {
let wanted: HashSet<Key> = hits.iter().map(|a| (sheet, *a)).collect();
for (a, arr) in &self.spills {
if wanted.contains(a) {
put(a.1, arr);
}
}
} else {
for a in &hits {
if let Some(arr) = self.spills.get(&(sheet, *a)) {
put(*a, arr);
}
}
}
}
@@ -625,7 +687,8 @@ impl Host for PassHost<'_> {
}
// Spilled values sit between the stored cells (and the formulas just calculated may have
// produced some): leave such blocks to `range_values`.
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
let mut arrays = Vec::new();
self.spill_index.meeting(sheet, range, &mut arrays);
let mut stored = false;
let _seen = stored_spill_in(sh, range, &mut |_, _| {
stored = true;
@@ -635,7 +698,7 @@ impl Host for PassHost<'_> {
{
self.spill_visits += _seen;
}
if arrays || stored {
if !arrays.is_empty() || stored {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
@@ -646,6 +709,50 @@ impl Host for PassHost<'_> {
}
true
}
fn range_sum(&mut self, sheet: usize, range: RangeRef) -> Option<(f64, u64)> {
// Constants can't change during a pass, so a block's result stays valid for it. Spills
// found in this pass (and iteration, which tracks what a read depends on) go the long way.
if self.iterate || self.spills.keys().any(|a| a.0 == sheet) {
return None;
}
let sh = self.wb.sheet(sheet)?;
let (key, last) = ((sheet, range.start, range.end.col), range.end.row);
let known = self.sums.entry(key).or_default();
if let Some(r) = known.get(&last) {
return *r;
}
// Start from the longest shorter block of the same rows from the top: summing row-major
// from there on is the same additions in the same order as one walk from the start.
let (from, mut sum, mut count) = match known.range(..last).next_back() {
Some((_, None)) => {
known.insert(last, None);
return None;
}
Some((&row, &Some((sum, count)))) => (row + 1, sum, count),
None => (range.start.row, 0.0, 0),
};
let rest = RangeRef::new(CellRef::new(from, range.start.col), range.end);
let mut plain = sh.spill.range(rest.start..=rest.end).all(|(c, _)| !rest.contains(*c));
if plain {
for (_, cell) in sh.cells.iter_range(rest) {
match &cell.value {
_ if cell.formula.is_some() || cell.py.is_some() => plain = false,
Value::Error(_) => plain = false,
Value::Number(n) => {
sum += n;
count = u64::saturating_add(count, 1);
}
_ => {}
}
if !plain {
break;
}
}
}
let result = plain.then_some((sum, count));
known.insert(last, result);
result
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
+296
View File
@@ -0,0 +1,296 @@
//! A spatial index over spill areas: which areas cover a cell, and which meet a range, without
//! looking at every area. Areas are registered in 16 x 16 cell blocks, so a lookup touches one
//! block (a cell) or the blocks a range covers (a range) rather than every spill on the sheet.
//! An area that would take more than `BIG_AREA_BLOCKS` blocks (a full-height column spill takes
//! 65,536) is not registered in blocks but kept in a short list that every lookup tests directly,
//! so registering costs what the area's size warrants only for small areas.
use crate::recalc::{FxHasher, Key};
use gridcraft_core::{CellRef, RangeRef};
use std::hash::BuildHasherDefault;
type HashMap<K, V> = std::collections::HashMap<K, V, BuildHasherDefault<FxHasher>>;
/// Block edge in cells (rows and columns).
const BLOCK: u32 = 16;
/// Areas spanning more blocks than this are tested directly instead of being registered.
const BIG_AREA_BLOCKS: u64 = 1024;
/// One indexed area: its rectangle, and whether it covers no cells (an array with no rows or no
/// columns still meets the ranges its anchor is in, as a 1 x 1 area does for taint tracking).
#[derive(Clone, Copy)]
struct Entry {
rect: RangeRef,
empty: bool,
}
#[derive(Default)]
struct SheetAreas {
entries: HashMap<CellRef, Entry>,
blocks: HashMap<(u32, u32), Vec<CellRef>>,
/// Anchors of the areas too big to register in blocks.
big: Vec<CellRef>,
}
/// What covers a cell.
pub(crate) enum Cover {
None,
One(CellRef),
/// Several areas overlap here: the caller decides which one counts.
Many,
}
#[derive(Default)]
pub(crate) struct SpillIndex {
sheets: HashMap<usize, SheetAreas>,
}
fn block_span(rect: &RangeRef) -> (u32, u32, u32, u32) {
(rect.start.row / BLOCK, rect.end.row / BLOCK, rect.start.col / BLOCK, rect.end.col / BLOCK)
}
fn block_count(rect: &RangeRef) -> u64 {
let (r0, r1, c0, c1) = block_span(rect);
u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1)
}
/// Whether registering `areas` in blocks costs less than checking each of them against each of
/// `lookups` cells would: a few block entries cost about one containment check each. A tall or
/// wide area is not registered at all (see `BIG_AREA_BLOCKS`) and so costs nothing here.
pub(crate) fn indexing_pays<'a>(areas: impl ExactSizeIterator<Item = &'a RangeRef>, lookups: usize) -> bool {
const BLOCK_COST: u64 = 16;
let budget = (lookups as u64).saturating_mul(areas.len() as u64) / BLOCK_COST;
let mut blocks = 0u64;
for r in areas {
let n = block_count(r);
if n > BIG_AREA_BLOCKS {
continue;
}
blocks = blocks.saturating_add(n);
if blocks > budget {
return false;
}
}
true
}
impl SpillIndex {
/// Whether any area is indexed on `sheet`.
#[cfg(test)]
pub(crate) fn has_sheet(&self, sheet: usize) -> bool {
self.sheets.get(&sheet).is_some_and(|s| !s.entries.is_empty())
}
/// Indexes the area an array of `rows` x `cols` anchored at `anchor` covers (replacing the
/// one already indexed for that anchor).
pub(crate) fn insert(&mut self, (sheet, anchor): Key, rows: usize, cols: usize) {
let end =
CellRef::new(anchor.row.saturating_add((rows as u32).saturating_sub(1)), anchor.col.saturating_add((cols as u32).saturating_sub(1)));
self.insert_rect((sheet, anchor), RangeRef::new(anchor, end), rows == 0 || cols == 0);
}
/// Indexes an area given as a range.
pub(crate) fn insert_rect(&mut self, (sheet, anchor): Key, rect: RangeRef, empty: bool) {
self.remove((sheet, anchor));
let areas = self.sheets.entry(sheet).or_default();
if block_count(&rect) > BIG_AREA_BLOCKS {
areas.big.push(anchor);
} else {
let (r0, r1, c0, c1) = block_span(&rect);
for br in r0..=r1 {
for bc in c0..=c1 {
areas.blocks.entry((br, bc)).or_default().push(anchor);
}
}
}
areas.entries.insert(anchor, Entry { rect, empty });
}
pub(crate) fn remove(&mut self, (sheet, anchor): Key) {
let Some(areas) = self.sheets.get_mut(&sheet) else { return };
let Some(old) = areas.entries.remove(&anchor) else { return };
if block_count(&old.rect) > BIG_AREA_BLOCKS {
areas.big.retain(|a| *a != anchor);
return;
}
let (r0, r1, c0, c1) = block_span(&old.rect);
for br in r0..=r1 {
for bc in c0..=c1 {
if let Some(list) = areas.blocks.get_mut(&(br, bc)) {
list.retain(|a| *a != anchor);
if list.is_empty() {
areas.blocks.remove(&(br, bc));
}
}
}
}
}
/// The areas that cover `c`.
pub(crate) fn covering(&self, sheet: usize, c: CellRef) -> Cover {
let Some(areas) = self.sheets.get(&sheet) else { return Cover::None };
let in_block = areas.blocks.get(&(c.row / BLOCK, c.col / BLOCK)).map_or(&[][..], |l| l.as_slice());
let mut found = Cover::None;
for a in in_block.iter().chain(&areas.big) {
if let Some(e) = areas.entries.get(a)
&& !e.empty
&& e.rect.contains(c)
{
found = match found {
Cover::None => Cover::One(*a),
_ => return Cover::Many,
};
}
}
found
}
/// The anchors of the areas whose rectangle meets `range` (an empty area counts through its
/// anchor cell), sorted by anchor.
pub(crate) fn meeting(&self, sheet: usize, range: RangeRef, out: &mut Vec<CellRef>) {
out.clear();
let Some(areas) = self.sheets.get(&sheet) else { return };
let (r0, r1, c0, c1) = block_span(&range);
let touched = u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1);
if touched > 4 * areas.entries.len() as u64 + 16 {
// A range wider than the areas are many: look at each area once.
out.extend(areas.entries.iter().filter(|(_, e)| e.rect.intersects(&range)).map(|(a, _)| *a));
} else {
for br in r0..=r1 {
for bc in c0..=c1 {
if let Some(list) = areas.blocks.get(&(br, bc)) {
out.extend(list.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range))));
}
}
}
out.extend(areas.big.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range))));
}
out.sort_unstable();
out.dedup();
}
/// The rectangle indexed for `anchor` and whether it covers no cells.
pub(crate) fn area(&self, sheet: usize, anchor: CellRef) -> Option<(RangeRef, bool)> {
self.sheets.get(&sheet)?.entries.get(&anchor).map(|e| (e.rect, e.empty))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(r: u32, c: u32) -> CellRef {
CellRef::new(r, c)
}
#[test]
fn finds_the_areas_covering_a_cell_and_meeting_a_range() {
let mut ix = SpillIndex::default();
ix.insert((0, at(0, 0)), 40, 3);
ix.insert((0, at(30, 1)), 1, 40);
ix.insert((1, at(0, 0)), 5, 5);
assert!(matches!(ix.covering(0, at(39, 2)), Cover::One(a) if a == at(0, 0)));
assert!(matches!(ix.covering(0, at(30, 2)), Cover::Many));
assert!(matches!(ix.covering(0, at(40, 0)), Cover::None));
assert!(matches!(ix.covering(2, at(0, 0)), Cover::None));
let mut hits = Vec::new();
ix.meeting(0, RangeRef::new(at(30, 30), at(50, 60)), &mut hits);
assert_eq!(hits, vec![at(30, 1)]);
ix.meeting(0, RangeRef::new(at(0, 0), at(1_000_000, 16_000)), &mut hits);
assert_eq!(hits, vec![at(0, 0), at(30, 1)]);
ix.remove((0, at(30, 1)));
assert!(matches!(ix.covering(0, at(30, 2)), Cover::One(_)));
assert!(ix.has_sheet(1) && !ix.has_sheet(2));
ix.insert((0, at(0, 0)), 1, 1);
assert!(matches!(ix.covering(0, at(39, 2)), Cover::None));
}
#[test]
fn tall_or_wide_areas_are_not_registered_in_blocks() {
let column = RangeRef::new(at(0, 0), at(1_048_575, 0));
assert!(block_count(&column) > BIG_AREA_BLOCKS);
let mut ix = SpillIndex::default();
ix.insert_rect((0, at(0, 0)), column, false);
assert!(ix.sheets[&0].blocks.is_empty());
assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::One(a) if a == at(0, 0)));
assert!(matches!(ix.covering(0, at(900_000, 1)), Cover::None));
let mut hits = Vec::new();
ix.meeting(0, RangeRef::new(at(5, 0), at(6, 3)), &mut hits);
assert_eq!(hits, vec![at(0, 0)]);
ix.remove((0, at(0, 0)));
assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::None) && ix.sheets[&0].big.is_empty());
// Registering a big area costs nothing (lookups test it directly), so a small paste still indexes next to many small areas.
assert!(indexing_pays([column].iter(), 17));
// Many small areas pay back for a paste of a hundred cells, but not for a handful.
let small: Vec<RangeRef> = (0..1_000).map(|i| RangeRef::new(at(i * 3, 0), at(i * 3 + 2, 0))).collect();
assert!(indexing_pays(small.iter(), 100));
assert!(!indexing_pays(small.iter(), 17));
}
/// The index against the old way: test every area for every lookup. Mixed shapes: small,
/// thin and tall, wide, big (kept out of the blocks), empty, on two sheets, replaced and removed.
#[test]
fn matches_a_scan_of_every_area_on_mixed_shapes() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut next = |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as u32 % n
};
let mut ix = SpillIndex::default();
let mut model: std::collections::BTreeMap<(usize, CellRef), (RangeRef, bool)> = Default::default();
for step in 0..3_000 {
let sheet = next(2) as usize;
let anchor = at(next(400) * 5, next(40) * 3);
if next(4) == 0 {
ix.remove((sheet, anchor));
model.remove(&(sheet, anchor));
} else {
let (rows, cols) = match next(8) {
0 => (0, 1 + next(5) as usize),
1 => (1 + next(5) as usize, 0),
2 => (5_000 + next(100_000) as usize, 1),
3 => (1, 1 + next(40_000) as usize),
4 => (200 + next(300) as usize, 40 + next(40) as usize),
_ => (1 + next(60) as usize, 1 + next(40) as usize),
};
ix.insert((sheet, anchor), rows, cols);
let end = at(anchor.row + (rows as u32).saturating_sub(1), anchor.col + (cols as u32).saturating_sub(1));
model.insert((sheet, anchor), (RangeRef::new(anchor, end), rows == 0 || cols == 0));
}
if step % 7 != 0 {
continue;
}
let sheet = next(3) as usize;
for _ in 0..20 {
let cell = at(next(2_100), next(130));
let want: Vec<CellRef> =
model.iter().filter(|((s, _), (r, e))| *s == sheet && !e && r.contains(cell)).map(|((_, a), _)| *a).collect();
match ix.covering(sheet, cell) {
Cover::None => assert!(want.is_empty(), "{cell:?}"),
Cover::One(a) => assert_eq!(want, vec![a], "{cell:?}"),
Cover::Many => assert!(want.len() > 1, "{cell:?}"),
}
let (r0, c0) = (next(2_100), next(130));
let (tall, wide) = (if next(5) == 0 { 3_000 } else { 40 }, if next(5) == 0 { 130 } else { 20 });
let range = RangeRef::new(at(r0, c0), at(r0 + next(tall), c0 + next(wide)));
let want: Vec<CellRef> = model.iter().filter(|((s, _), (r, _))| *s == sheet && r.intersects(&range)).map(|((_, a), _)| *a).collect();
let mut got = Vec::new();
ix.meeting(sheet, range, &mut got);
assert_eq!(got, want, "{range:?}");
}
}
}
#[test]
fn an_area_without_cells_covers_none_but_is_met_at_its_anchor() {
let mut ix = SpillIndex::default();
ix.insert((0, at(3, 3)), 0, 4);
assert!(matches!(ix.covering(0, at(3, 3)), Cover::None));
let mut hits = Vec::new();
ix.meeting(0, RangeRef::new(at(3, 3), at(3, 3)), &mut hits);
assert_eq!(hits, vec![at(3, 3)]);
}
}
+525 -1
View File
@@ -1826,6 +1826,54 @@ fn trim_references() {
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
}
#[test]
fn lookup_of_an_empty_cell_stays_blank() {
// #374: B1 (the value for key "k") is empty.
let mut t = T::new();
t.set("A1", "k");
t.set("A2", "j");
t.set("B2", "v");
for (at, f, want) in [
("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")),
("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")),
("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)),
("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")),
// A bare lookup still shows 0, like a reference to an empty cell.
("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)),
// Arithmetic still reads the blank as 0.
("D9", "=VLOOKUP(\"k\",A1:B2,2,0)+1", Value::Number(1.0)),
// A blank lookup given as an argument is still given, not omitted.
("D10", "=FILTER(1,FALSE,VLOOKUP(\"k\",A1:B2,2,FALSE))", Value::Number(0.0)),
("D11", "=XLOOKUP(\"z\",A1:A2,B1:B2,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
// The same holds per element when the call is lifted over an array of blank lookups.
("D12", "=CEILING.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
("D13", "=SUM(CEILING.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)),
("D14", "=FLOOR.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
("D15", "=SUM(FLOOR.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)),
] {
t.set(at, f);
assert_eq!(t.get(at), want, "{f}");
}
}
#[test]
fn blank_lookup_values_match_excel_windows_observation() {
// Excel 365 Windows 2609, build 16.0.20430.20032, black-box check 2026-10-13:
// A1="k", B1 and A2 genuinely empty.
// All three value lookups preserve blankness inside ISBLANK/concatenation.
let mut t = T::new();
t.set("A1", "k");
for (at, f, want) in [
("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B1,2,FALSE))", Value::Bool(true)),
("D2", "=ISBLANK(HLOOKUP(\"k\",A1:A2,2,FALSE))", Value::Bool(true)),
("D3", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)),
("D4", "=\"x\"&HLOOKUP(\"k\",A1:A2,2,FALSE)&\"y\"", Value::from("xy")),
] {
t.set(at, f);
assert_eq!(t.get(at), want, "{f}");
}
}
#[test]
fn cell_width_is_the_column_width_dialog_value_rounded() {
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
@@ -1922,6 +1970,119 @@ fn offset_extreme_arguments_return_ref() {
}
}
/// `@` intersects a reference with the formula's row (a column range) or column (a row range);
/// a 2D range both (circular when it includes the formula's cell); a range the formula does not
/// line up with gives #VALUE!. An array gives its top-left value. Legacy formulas read from files use it where Excel intersects implicitly.
#[test]
fn implicit_intersection_operator() {
let mut t = T::new();
for (at, v) in [("A1", "1"), ("A2", "2"), ("A3", "3"), ("A5", "4"), ("B5", "5"), ("C5", "6")] {
t.set(at, v);
}
t.set("B2", "=TANH(@A1:A3)");
t.set("B7", "=TANH(@A5:C5)");
t.set("B3", "=@A1:C3");
t.set("B9", "=@A1:A3");
t.set("E9", "=@A5:C5");
t.set("D1", "=@{7,8;9,10}");
t.set("D2", "=@A2");
t.set("D3", "=SUM(@A1:A3)");
assert_eq!(t.num("B2"), 2f64.tanh());
assert_eq!(t.num("B7"), 5f64.tanh());
for at in ["B9", "E9"] {
assert_eq!(t.get(at), Value::Error(CellError::Value), "{at}");
}
// A 2D range that includes the formula's own cell is a circular reference (Excel shows 0).
assert_eq!(t.get("B3"), Value::Error(CellError::Circ));
// From another sheet a 2D range intersects on both row and column; outside it is #VALUE!.
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Other")));
for (at, input) in [("B2", "=@Sheet1!A1:C3"), ("C1", "=@Sheet1!A1:C3"), ("B9", "=@Sheet1!A1:C3"), ("E2", "=@Sheet1!A1:C3")] {
t.wb.sheet_mut(1).unwrap().cells.set(c(at), Cell::formula(Formula::new(input)));
}
t.calc.recalc_all(&mut t.wb);
let other = |at: &str| t.wb.sheet(1).unwrap().value(c(at));
assert_eq!(other("B2"), Value::Number(2f64.tanh())); // Sheet1!B2
assert_eq!(other("C1"), Value::Number(0.0));
assert_eq!(other("B9"), Value::Error(CellError::Value));
assert_eq!(other("E2"), Value::Error(CellError::Value));
assert_eq!(t.num("D1"), 7.0);
assert_eq!(t.num("D2"), 2.0);
assert_eq!(t.num("D3"), 3.0);
// An array result shows its top-left value (T of a reference reads its top-left cell).
t.set("A11", "x");
t.set("C11", "y");
t.set("E1", "=@T(A11:C11)");
assert_eq!(t.get("E1"), Value::text("x"));
// Without `@` (a dynamic-array formula) the same formula spills.
t.set("F1", "=TANH(A1:A3)");
assert_eq!((t.num("F1"), t.num("F2"), t.num("F3")), (1f64.tanh(), 2f64.tanh(), 3f64.tanh()));
}
/// "Set precision as displayed": results are rounded to their cell's format before they are
/// stored and before other formulas read them. Values observed in Excel (COM,
/// `Workbook.PrecisionAsDisplayed = True`) with the same synthetic formulas.
#[test]
fn precision_as_displayed_rounds_results_by_cell_format() {
let fmt = |t: &mut T, at: &str, code: &str| {
let id = t.wb.styles.intern(gridcraft_model::Style { num_fmt: gridcraft_model::NumFmt::new(code), ..Default::default() });
t.wb.sheet_mut(0).unwrap().set_style(c(at), id);
};
let mut t = T::new();
t.set("A1", "=-130+657.91");
t.set("A2", "=A1*10");
t.set("A3", "=A1/7");
// Cross-cell: E2 rounds what it reads from E1; E1 itself (General) keeps full precision.
t.set("E1", "=10/3");
t.set("E2", "=E1");
t.set("E3", "=E2*10");
t.set("E4", "=E1*10");
// A spill: each element takes the format of the cell it lands in.
t.set("F1", "=SEQUENCE(3,1,0.123,0.111)");
t.set("G4", "=SUM(F1:F3)");
t.set("H1", "=TRUE");
t.set("H2", "=\"1.5\"");
// A conditional format rounds by the sign's section, not the one displayed: `[<1]0.00;0`
// shows 2+1/3 as `2` but stores 2.33, and stores -10/3 as -3.
t.set("J1", "=2+1/3");
t.set("J2", "=-10/3");
t.set("K1", "=J1*3");
t.set("K2", "=J2*3");
fmt(&mut t, "J1", "[<1]0.00;0");
fmt(&mut t, "J2", "[<1]0.00;0");
fmt(&mut t, "A1", "0");
fmt(&mut t, "A3", "0.0000");
fmt(&mut t, "E2", "0");
fmt(&mut t, "F1", "0.0");
fmt(&mut t, "F2", "0.0");
fmt(&mut t, "H1", "0");
fmt(&mut t, "H2", "0");
// Off: formats don't change values.
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), -130.0 + 657.91);
assert_eq!(t.num("E2"), 10.0 / 3.0);
t.wb.calc.precision_as_displayed = true;
t.calc.recalc_all(&mut t.wb);
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (528.0, 5280.0, 75.4286));
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (10.0 / 3.0, 3.0, 30.0, 10.0 / 3.0 * 10.0));
let sh = t.wb.sheet(0).unwrap();
assert_eq!(
(sh.value(c("F1")), sh.value(c("F2")), sh.value(c("F3"))),
(Value::Number(0.1), Value::Number(0.2), Value::Number(0.123 + 0.111 * 2.0))
);
assert_eq!(t.num("G4"), 0.1 + 0.2 + (0.123 + 0.111 * 2.0));
assert_eq!((t.num("J1"), t.num("J2"), t.num("K1"), t.num("K2")), (2.33, -3.0, 2.33 * 3.0, -9.0));
// Booleans and text are left alone.
assert_eq!((t.get("H1"), t.get("H2")), (Value::Bool(true), Value::from("1.5")));
// An edit recalculates through the rounded values too.
t.set("A1", "=-130+657.41");
fmt(&mut t, "A1", "0");
t.calc.cells_changed(&mut t.wb, &[(0, c("A1"))]);
assert_eq!((t.num("A1"), t.num("A2")), (527.0, 5270.0));
// Off again: a recalculation brings back full precision.
t.wb.calc.precision_as_displayed = false;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A2"), (-130.0 + 657.41) * 10.0);
}
#[test]
fn analysis_toolpak_value_params_reject_multi_cell_references() {
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
@@ -2006,7 +2167,6 @@ fn analysis_toolpak_value_params_reject_multi_cell_references() {
t.set("H5", "");
}
}
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
@@ -2313,6 +2473,354 @@ fn loan_schedule_residue_keeps_excels_sign() {
assert_eq!(t.get("P7"), Value::Text("".into()));
}
#[test]
fn clearing_a_cell_unblocks_the_arrays_above_and_left_of_it() {
let mut t = T::new();
for r in 1..=50 {
t.set(&format!("D{r}"), &format!("={r}*2"));
}
t.set("B2", "7");
t.set("B1", "=SEQUENCE(3)");
t.set("C1", "=SEQUENCE(1,3)");
t.set("A20", "=B1+1");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
assert_eq!(t.get("A20"), Value::Error(CellError::Spill));
// Emptied: the array above it spills.
t.wb.sheet_mut(0).unwrap().cells.remove(c("B2"));
t.calc.cells_changed(&mut t.wb, &[(0, c("B2"))]);
assert_eq!(t.num("B3"), 3.0);
assert_eq!(t.num("A20"), 2.0);
// Typing over the area blocks it again; far from it, nothing is disturbed.
t.set("B3", "9");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
t.set("D50", "1");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
assert_eq!(t.num("D49"), 98.0);
// Clearing it again frees the array.
t.set("B3", "");
assert_eq!(t.num("B3"), 3.0);
}
/// A sheet with `#SPILL!` cells in rows 0..64, columns 100..16384 (as blocked arrays leave), none
/// near column A.
fn spill_error_field() -> T {
let mut t = T::new();
let sh = t.wb.sheet_mut(0).unwrap();
for r in 0..64 {
for col in 100..16_384 {
sh.cells.set(CellRef::new(r, col), Cell::value(Value::Error(CellError::Spill)));
}
}
t.calc.recalc_all(&mut t.wb);
t
}
#[test]
fn an_edit_far_left_of_many_spill_errors_does_not_disturb_them() {
let mut t = spill_error_field();
t.set("A65", "5");
assert_eq!(t.num("A65"), 5.0);
assert_eq!(t.get("CW1"), Value::Error(CellError::Spill));
assert_eq!(t.get("XFD64"), Value::Error(CellError::Spill));
// A blocked formula inside the window is still found when a cell to its right is cleared.
t.set("DA66", "7");
t.set("CZ66", "=SEQUENCE(1,3)");
assert_eq!(t.get("CZ66"), Value::Error(CellError::Spill));
t.wb.sheet_mut(0).unwrap().cells.remove(c("DA66"));
t.calc.cells_changed(&mut t.wb, &[(0, c("DA66"))]);
assert_eq!(t.num("DA66"), 2.0);
}
/// Probe: single-cell edits in column A below a block of `#SPILL!` cells to its right.
/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture spill_error_field_probe`
#[test]
#[ignore]
#[allow(clippy::disallowed_methods)]
fn spill_error_field_probe() {
let mut t = spill_error_field();
let mut best = std::time::Duration::MAX;
for i in 0..9 {
let start = std::time::Instant::now();
for _ in 0..100 {
t.set("A65", &format!("{i}"));
}
best = best.min(start.elapsed());
}
eprintln!("100 edits of A65 beside 1,040,000 spill errors: best of 9 = {best:?}");
}
/// A full-height column spill (inside the 10,000,000-cell cap) and a paste of 17 cells elsewhere:
/// the paste must not register the spill's 65,536 blocks, and a paste over the spill must still
/// block it.
fn tall_spill_book() -> T {
let mut t = T::new();
t.set("A1", "=SEQUENCE(1048576)");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1048576"), 1_048_576.0);
t
}
fn paste_into(t: &mut T, cells: &[(u32, u32)]) {
let changed: Vec<(usize, CellRef)> = cells.iter().map(|&(r, c)| (0, CellRef::new(r, c))).collect();
for k in &changed {
t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
}
t.calc.cells_changed(&mut t.wb, &changed);
}
#[test]
fn a_paste_beside_a_tall_spill_leaves_it_alone_and_one_over_it_blocks_it() {
let mut t = tall_spill_book();
let beside: Vec<(u32, u32)> = (0..17).map(|i| (i, 5)).collect();
paste_into(&mut t, &beside);
assert_eq!(t.num("A1048576"), 1_048_576.0);
let over: Vec<(u32, u32)> = (0..17).map(|i| (500_000 + i, if i == 9 { 0 } else { 5 })).collect();
paste_into(&mut t, &over);
assert_eq!(t.get("A1"), Value::Error(CellError::Spill));
}
/// Probe: `cells_changed` for a 17-cell paste in column F beside the tall spill.
/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture tall_spill_paste_probe`
#[test]
#[ignore]
#[allow(clippy::disallowed_methods)]
fn tall_spill_paste_probe() {
let mut t = tall_spill_book();
let mut best = std::time::Duration::MAX;
for round in 0..9u32 {
let beside: Vec<(u32, u32)> = (0..17).map(|i| (round * 20 + i, 5)).collect();
let start = std::time::Instant::now();
paste_into(&mut t, &beside);
best = best.min(start.elapsed());
}
eprintln!("tall spill, 17-cell paste beside it: best of 9 = {best:?}");
}
#[test]
fn many_spills_are_read_and_blocked_by_a_paste() {
let mut t = T::new();
// 40 arrays of 3 rows side by side in rows 1-3, and a reader of a range over two of them.
for i in 0..40u32 {
let anchor = CellRef::new(0, 2 * i);
t.wb.sheet_mut(0).unwrap().cells.set(anchor, Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i))));
}
t.set("A10", "=SUM(C1:F3)");
t.set("A12", "=COUNT(C1:F3)");
t.set("A11", "=C2*1");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A10"), (10 + 11 + 12 + 20 + 21 + 22) as f64);
assert_eq!(t.num("A11"), 11.0);
assert_eq!(t.num("A12"), 6.0);
// Pasting 20 cells (more than one spill's width worth, so the changed set is indexed) over
// the second row of every other array blocks those arrays; the rest are unchanged.
let changed: Vec<(usize, CellRef)> = (0..20u32).map(|i| (0, CellRef::new(1, 4 * i))).collect();
for k in &changed {
t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
}
t.calc.cells_changed(&mut t.wb, &changed);
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
assert_eq!(t.get("C1"), Value::Number(10.0));
assert_eq!(t.get("G2"), Value::Number(31.0));
assert_eq!(t.get("A10"), Value::Error(CellError::Spill));
assert_eq!(t.num("A12"), 4.0);
}
#[test]
fn a_paste_over_small_and_tall_spills_blocks_those_it_touches() {
let mut t = T::new();
for i in 0..40u32 {
t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 2 * i), Cell::formula(Formula::new("=SEQUENCE(3)")));
}
t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 200), Cell::formula(Formula::new("=SEQUENCE(1048576)")));
t.calc.recalc_all(&mut t.wb);
// 20 cells: the middle row of the small arrays in columns 0, 8, 16 ... 72 (columns 80 and up hit
// nothing), and one deep inside the tall one.
let mut cells: Vec<(u32, u32)> = (0..19u32).map(|i| (1, 8 * i)).collect();
cells.push((700_000, 200));
paste_into(&mut t, &cells);
assert_eq!(t.get("A1"), Value::Error(CellError::Spill));
assert_eq!(t.get("GS1"), Value::Error(CellError::Spill));
assert_eq!(t.num("C1"), 1.0);
assert_eq!(t.num("C3"), 3.0);
assert_eq!(t.get("I1"), Value::Error(CellError::Spill));
assert_eq!(t.num("E3"), 3.0);
}
#[test]
fn a_big_paste_on_one_sheet_and_one_cell_over_many_spills_on_another() {
let mut t = T::new();
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
for i in 0..1_500u32 {
t.wb.sheet_mut(1).unwrap().cells.set(CellRef::new(4 * i, 0), Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i))));
}
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.wb.sheet(1).unwrap().value(CellRef::new(4 * 700 + 2, 0)), Value::Number(7002.0));
// 5000 cells pasted on the first sheet, one over the middle of the 701st array on the second.
let mut changed: Vec<(usize, CellRef)> = (0..5_000u32).map(|i| (0, CellRef::new(i / 50, i % 50))).collect();
changed.push((1, CellRef::new(4 * 700 + 1, 0)));
for k in &changed {
t.wb.sheet_mut(k.0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
}
t.calc.cells_changed(&mut t.wb, &changed);
let data = t.wb.sheet(1).unwrap();
assert_eq!(data.value(CellRef::new(4 * 700, 0)), Value::Error(CellError::Spill));
assert_eq!(data.value(CellRef::new(4 * 699 + 2, 0)), Value::Number(6992.0));
assert_eq!(data.value(CellRef::new(4 * 701 + 2, 0)), Value::Number(7012.0));
}
/// `SUM`, `COUNT` and `AVERAGE` over blocks of constants share one pass-wide answer per block, and a
/// running total extends the shorter block's. Every result must equal the left-to-right sum of the
/// cells, bit for bit, with text, blanks, errors, formulas and a nonzero start in the mix.
#[test]
fn aggregates_over_ranges_match_a_plain_fold_bit_for_bit() {
struct Rng(u64);
impl Rng {
fn next(&mut self, n: u64) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0 % n
}
}
let nums = [0.1, 0.2, 0.3, 1e16, -1e16, 1.0, 3.7, -0.0, 12345.678, 1e-9, 7.0];
let rows = 40u32;
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let mut wb = Workbook::new();
let fresh = |rng: &mut Rng, plain: bool| match rng.next(if plain { 8 } else { 14 }) {
0 => Cell::value(Value::text("t")),
1 => Cell::default(),
2 => Cell::value(Value::Bool(true)),
3 if !plain => Cell::value(Value::Error(CellError::NA)),
4 if !plain => Cell::formula(Formula::new("=A1*2")),
5 if !plain => Cell::formula(Formula::new("=\"x\"&B2")),
_ => Cell::value(Value::Number(nums[rng.next(nums.len() as u64) as usize])),
};
// Columns A:C mostly numbers; E holds a formula column and some errors only in a few rows.
for r in 0..rows {
for col in 0..3 {
let plain = !(r % 13 == 7 && col == 1);
wb.sheet_mut(0).unwrap().cells.set(CellRef::new(r, col), fresh(&mut rng, plain));
}
}
// Formulas in column H and up: (function, optional leading number, blocks).
type Oracle<'a> = (CellRef, &'a str, Option<f64>, Vec<(u32, u32, u32, u32)>);
let mut oracle: Vec<Oracle> = vec![];
let funcs = ["SUM", "COUNT", "AVERAGE"];
let mut at = 0;
let mut add = |wb: &mut Workbook, f: &'static str, lead: Option<f64>, blocks: Vec<(u32, u32, u32, u32)>| {
let args: Vec<String> = blocks
.iter()
.map(|&(r0, c0, r1, c1)| format!("{}{}:{}{}", (b'A' + c0 as u8) as char, r0 + 1, (b'A' + c1 as u8) as char, r1 + 1))
.collect();
let lead_s = lead.map(|l| format!("{l:e},")).unwrap_or_default();
let cell = CellRef::new(at % rows, 7 + at / rows);
wb.sheet_mut(0).unwrap().cells.set(cell, Cell::formula(Formula::new(&format!("={f}({lead_s}{})", args.join(",")))));
oracle.push((cell, f, lead, blocks));
at += 1;
};
// Running totals over a clean column and over a column with an error further down.
for r in 0..rows {
add(&mut wb, "SUM", None, vec![(0, 0, r, 0)]);
add(&mut wb, "SUM", None, vec![(0, 1, r, 1)]);
add(&mut wb, "COUNT", None, vec![(0, 0, r, 2)]);
}
for _ in 0..120 {
let (r0, r1) = (rng.next(rows as u64) as u32, rng.next(rows as u64) as u32);
let (c0, c1) = (rng.next(3) as u32, rng.next(3) as u32);
let n = 1 + rng.next(2) as usize;
let blocks = (0..n).map(|_| (r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))).collect();
let lead = [None, Some(1e16), Some(0.5), Some(2.0)][rng.next(4) as usize];
let f = funcs[rng.next(3) as usize];
add(&mut wb, f, lead, blocks);
}
let check = |wb: &Workbook, when: &str| {
let sh = wb.sheet(0).unwrap();
for (cell, f, lead, blocks) in &oracle {
let (mut sum, mut count, mut error) = (lead.unwrap_or(0.0), u64::from(lead.is_some()), None);
for &(r0, c0, r1, c1) in blocks {
for r in r0..=r1 {
for col in c0..=c1 {
match sh.value(CellRef::new(r, col)) {
Value::Number(n) => {
sum += n;
count += 1;
}
Value::Error(e) if *f != "COUNT" => error = error.or(Some(e)),
_ => {}
}
}
}
}
let want = match (error, *f) {
(Some(e), _) => Value::Error(e),
(_, "SUM") => Value::number(sum),
(_, "COUNT") => Value::number(count as f64),
(_, _) if count == 0 => Value::Error(CellError::Div0),
_ => Value::number(sum / count as f64),
};
let got = sh.value(*cell);
let same = match (&got, &want) {
(Value::Number(a), Value::Number(b)) => a.to_bits() == b.to_bits(),
(a, b) => a == b,
};
assert!(same, "{when}: {:?} = {:?}, plain fold {:?}", cell, got, want);
}
};
let mut calc = Calc::new();
calc.rebuild(&wb);
calc.recalc_all(&mut wb);
check(&wb, "full recalc");
for round in 0..30 {
let mut changed = vec![];
for _ in 0..1 + rng.next(4) {
let at = CellRef::new(rng.next(rows as u64) as u32, rng.next(3) as u32);
wb.sheet_mut(0).unwrap().cells.set(at, fresh(&mut rng, round % 2 == 0));
changed.push((0, at));
}
calc.cells_changed(&mut wb, &changed);
check(&wb, &format!("edit round {round}"));
}
}
/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the
/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that
/// carry their tree and for formulas that only carry their text (as after loading a file).
#[test]
fn graph_built_from_text_only_formulas_matches_cached() {
fn run(cached: bool) -> Vec<Value> {
let mut t = T::new();
t.set("A1", "2");
for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] {
// E1 does not parse, so it has no tree either way and only covers the `None` branch.
let mut formula = Formula::new(f);
if !cached {
formula.expr = None;
}
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula));
}
t.calc.rebuild(&t.wb);
t.calc.recalc_all(&mut t.wb);
let mut out: Vec<Value> = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect();
// Editing the input must reach the dependents through the rebuilt graph.
t.set("A1", "5");
out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a)));
// A text-only formula that is rewritten through `cells_changed` joins the graph too.
let mut formula = Formula::new("=A1+100");
if !cached {
formula.expr = None;
}
t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula));
t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]);
t.set("A1", "7");
out.push(t.get("F1"));
out
}
let cached = run(true);
assert_eq!(cached, run(false));
assert_eq!(cached[4], Value::Number(50.0));
assert_eq!(cached[7], Value::Number(107.0));
}
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
@@ -2372,3 +2880,19 @@ fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
}
/// A computed criterion whose formula runs a database function over the same criteria counts as
/// not met inside itself: before, each record's check ran the function again for every record, at
/// every level, so the work grew exponentially with the nesting allowed.
#[test]
fn computed_criterion_that_reenters_itself_is_not_met() {
let mut t = T::new();
for (r, (name, age)) in [("name", "age"), ("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("e", "5")].iter().enumerate() {
t.set(&format!("F{}", r + 2), name);
t.set(&format!("G{}", r + 2), age);
}
t.set("N46", "Formula");
t.set("N47", "=DCOUNT($F$2:$G$7,\"age\",$N$46:$N$47)>0");
t.set("B60", "=DCOUNT($F$2:$G$7,\"age\",N46:N47)");
assert_eq!(t.num("B60"), 0.0);
}
+4 -7
View File
@@ -25,9 +25,11 @@ impl Scale {
(0..=n)
.map(|i| {
let v = self.min + i as f64 * self.step;
// Snap away accumulated float error.
// Snap away accumulated float error, but only onto a multiple of the step that is
// really that close: a `min` off the step grid must keep its own ticks.
let snapped = (v / self.step).round() * self.step;
if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped }
let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v };
if v.abs() < self.step * 1e-9 { 0.0 } else { v }
})
.collect()
}
@@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale {
Scale { min, max, step }
}
/// Exact fixed scale (100% stacked axes).
pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale {
Scale { min, max, step }
}
/// A number format prepared once and reused for many labels.
pub(crate) struct Fmt {
general: bool,
+84 -7
View File
@@ -7,6 +7,20 @@ use crate::{Prim, Rgba};
/// Largest image we allocate (pixels).
const MAX_PIXELS: u64 = 64 * 1024 * 1024;
const SUB: usize = 4;
/// Scan-conversion work one [`rasterize`] call may do: edge tests (an active edge at one
/// sub-sample) plus covered pixels blended. Edges are only tested on the rows they cross, but a
/// caller can still pass enough geometry to keep the rasteriser busy for minutes on a small
/// image; once the allowance is spent the remaining fills are skipped. 2^28 is about a second,
/// far beyond any chart.
const MAX_SCAN_WORK: u64 = 1 << 28;
/// Most points one filled path may have: more is skipped before any edge is built. Chart
/// renderers cap and decimate their paths far below this; it only bounds direct callers.
const MAX_FILL_POINTS: usize = 1 << 22;
/// Most points one stroked polyline may have: more is skipped before any segment path is built
/// (each segment becomes its own quad). Charts hold at most 100,000 points per series and draw far
/// fewer; it only bounds direct callers.
const MAX_STROKE_POINTS: usize = 1 << 17;
type Path = Vec<Vec<[f32; 2]>>;
@@ -15,6 +29,8 @@ struct Canvas {
h: usize,
buf: Vec<u8>,
acc: Vec<f32>,
/// Scan-conversion work left (see [`MAX_SCAN_WORK`]).
work: u64,
}
impl Canvas {
@@ -41,7 +57,12 @@ impl Canvas {
/// Fills a multi-contour path with the even-odd rule.
fn fill(&mut self, path: &Path, color: Rgba) {
let mut edges: Vec<[f32; 4]> = Vec::new();
// Out of work, or more geometry than the allowance could ever scan: no edge list is built.
if self.work == 0 || path.iter().map(Vec::len).fold(0usize, usize::saturating_add) > MAX_FILL_POINTS {
return;
}
// Each edge as [top y, bottom y, x0, y0, x1, y1].
let mut edges: Vec<[f32; 6]> = Vec::new();
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for c in path {
if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
@@ -54,7 +75,7 @@ impl Canvas {
miny = miny.min(p[1]);
maxy = maxy.max(p[1]);
if p[1] != q[1] {
edges.push([p[0], p[1], q[0], q[1]]);
edges.push([p[1].min(q[1]), p[1].max(q[1]), p[0], p[1], q[0], q[1]]);
}
}
}
@@ -72,8 +93,28 @@ impl Canvas {
if self.acc.len() < bw + 1 {
self.acc.resize(bw + 1, 0.0);
}
// Active-edge list: edges join when the scan reaches their top and leave past their
// bottom, so each row tests only the edges crossing it instead of every edge.
edges.sort_by(|a, b| a[0].total_cmp(&b[0]));
let mut pending = edges.iter().peekable();
let mut active: Vec<[f32; 6]> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for py in y0..y1 {
while let Some(e) = pending.next_if(|e| e[0] < py as f32 + 1.0) {
active.push(*e);
}
active.retain(|e| e[1] > py as f32);
if active.is_empty() {
continue;
}
// The row's edge tests and its `bw` accumulator clears and blends, charged before any of
// them, so rows with no coverage still count.
let cost = (active.len() * SUB) as u64 + bw as u64;
if cost > self.work {
self.work = 0;
return;
}
self.work -= cost;
for v in self.acc.iter_mut().take(bw) {
*v = 0.0;
}
@@ -81,10 +122,9 @@ impl Canvas {
for s in 0..SUB {
let sy = py as f32 + (s as f32 + 0.5) / SUB as f32;
xs.clear();
for e in &edges {
let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) };
if sy >= ya && sy < yb {
xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1]));
for e in &active {
if sy >= e[0] && sy < e[1] {
xs.push(e[2] + (sy - e[3]) * (e[4] - e[2]) / (e[5] - e[3]));
}
}
xs.sort_by(f32::total_cmp);
@@ -154,6 +194,9 @@ fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
/// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel).
fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<Path> {
if pts.len() > MAX_STROKE_POINTS {
return Vec::new();
}
let width = width.clamp(1.0, 50.0);
let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect();
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
@@ -212,7 +255,7 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
for _ in 0..w * h {
buf.extend_from_slice(&background);
}
let mut cv = Canvas { w, h, buf, acc: Vec::new() };
let mut cv = Canvas { w, h, buf, acc: Vec::new(), work: MAX_SCAN_WORK };
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
@@ -274,6 +317,40 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
mod tests {
use super::*;
/// Security: each row tests only the edges crossing it. A polygon of 100,000 tiny edges over
/// 4,000 rows tested every edge at every sub-sample of every row (1.6 billion tests).
#[test]
fn many_short_edges_scan_only_their_rows() {
let mut pts: Vec<[f32; 2]> = (0..100_000).map(|i| [if i % 2 == 0 { 0.0 } else { 0.5 }, i as f32 * 0.04]).collect();
pts.push([2.0, 4_000.0]);
pts.push([2.0, 0.0]);
let img = rasterize(&[Prim::Polygon { pts, fill: [255, 0, 0, 255], stroke: None }], 2, 4_000, [0, 0, 0, 0]);
// The right-hand pixel of every row is inside the polygon.
assert_eq!(img.get(4..8), Some(&[255, 0, 0, 255][..]));
assert_eq!(img.get(4 * (2 * 3_999 + 1)..4 * (2 * 3_999 + 2)), Some(&[255, 0, 0, 255][..]));
}
/// Security: scan conversion stops once the call's work allowance is spent.
#[test]
fn fills_stop_when_the_work_allowance_is_spent() {
let square = vec![vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]];
let mut cv = Canvas { w: 4, h: 4, buf: vec![0; 64], acc: Vec::new(), work: 2 * SUB as u64 * 4 + 16 };
cv.fill(&square, [255; 4]);
assert!(cv.buf.iter().all(|&b| b == 255));
cv.buf = vec![0; 64];
cv.fill(&square, [255; 4]);
assert!(cv.buf.iter().all(|&b| b == 0));
}
/// Security: a polyline over the point cap builds no segment paths at all, solid or dashed.
#[test]
fn oversized_polylines_build_no_segment_paths() {
let line = |n: usize| -> Vec<[f32; 2]> { (0..n).map(|i| [i as f32, (i % 2) as f32]).collect() };
assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, false, false).is_empty());
assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, true, false).is_empty());
assert_eq!(stroke_paths(&line(MAX_STROKE_POINTS), 1.0, false, false).len(), MAX_STROKE_POINTS - 1);
}
#[test]
fn dashed_geometry_stays_within_the_dash_budget() {
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
+100 -14
View File
@@ -1,11 +1,12 @@
//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
use std::borrow::Cow;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
use crate::axis::{Fmt, Scale, clean, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, points_per_series, series_color};
use crate::trend::{auto_name, fit_trendline, moving_average};
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
@@ -19,7 +20,7 @@ mod trend;
use cartesian::{cartesian, scatter};
use frame::{Bx, axis_titles, chart_title};
use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall};
use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall};
use legend::draw_legend;
pub use legend::{Swatch, legend_entries};
pub use trend::supports_trendline;
@@ -44,31 +45,66 @@ const MAX_LINE_PTS: usize = 2000;
const MAX_MARKERS: usize = 5000;
/// Data labels are skipped when a series has more points than this.
const MAX_LABELS: usize = 200;
/// Columns and bars narrower than this (in pixels) are merged per pixel column into one rectangle.
const MIN_BAR_PX: f32 = 0.25;
/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
/// capped instead of the chart dropping rings wholesale.
pub(crate) const MAX_WEDGES: usize = 2_048;
struct Ctx<'a> {
out: Vec<Prim>,
m: &'a dyn Measure,
}
#[cfg(test)]
#[path = "render_perf_tests.rs"]
mod perf_tests;
impl Ctx<'_> {
fn tw(&self, s: &str, size: f32) -> f32 {
let w = self.m.text_width(s, size, false);
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
/// `s` shortened with an ellipsis to fit `maxw`; empty when not even one character fits.
///
/// Binary search over the number of kept characters (at most 400 are tried), about nine
/// `Measure` calls instead of one per character. It assumes the width of `prefix + "…"` does not
/// shrink as the prefix grows, which holds for per-character advance sums (`ApproxMeasure`, the
/// PDF metrics) and for the UI's text layout up to kerning of a few hundredths of a point; with
/// such a non-monotone measure the result may differ from the old linear scan by a character.
/// A label with a line break is not monotone (the ellipsis moves to a new line and the widest
/// line can get shorter), so it takes the linear scan, longest prefix first.
fn fit(&self, s: &str, size: f32, maxw: f32) -> String {
if self.tw(s, size) <= maxw {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len().min(400);
while n > 0 {
n -= 1;
let t: String = chars.iter().take(n).collect::<String>() + "…";
if self.tw(&t, size) <= maxw {
return if n == 0 { String::new() } else { t };
// Byte offset where each of the first 400 characters starts; a prefix of `k` characters
// (k < number of characters, so the offset exists) ends at `starts[k]`.
let starts: Vec<usize> = s.char_indices().map(|(i, _)| i).take(400).collect();
let top = starts.len().saturating_sub(1);
let fits = |k: usize| -> bool {
let end = starts.get(k).copied().unwrap_or(s.len());
let mut t = String::with_capacity(end + 3);
t.push_str(s.get(..end).unwrap_or(""));
t.push('…');
self.tw(&t, size) <= maxw
};
if s.contains('\n') {
let k = (1..=top).rev().find(|&k| fits(k));
return k.map_or_else(String::new, |k| format!("{}…", s.get(..starts.get(k).copied().unwrap_or(s.len())).unwrap_or("")));
}
let (mut lo, mut hi) = (0usize, top);
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if fits(mid) {
lo = mid;
} else {
hi = mid - 1;
}
}
String::new()
if lo == 0 { String::new() } else { format!("{}…", s.get(..starts.get(lo).copied().unwrap_or(s.len())).unwrap_or("")) }
}
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
if s.is_empty() {
@@ -96,7 +132,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect()
}
fn sanitize(d: &ChartData) -> ChartData {
pub(crate) fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
@@ -121,12 +157,62 @@ fn sanitize(d: &ChartData) -> ChartData {
}
}
/// Whether `v` survives `sanitize_vals` unchanged.
fn is_clean(v: &Option<f64>) -> bool {
v.is_none_or(|n| n.is_finite() && clean(n) == n)
}
/// Whether [`sanitize`] and [`apply_budget`] would change `d`.
fn needs_sanitizing(d: &ChartData) -> bool {
let cap = points_per_series(d.series.len());
let long = |n: usize| n > cap;
let mut trendlines = 0usize;
d.palette.len() > MAX_POINTS
|| long(d.categories.len())
|| d.category_levels.len() > 3
|| d.category_levels.iter().any(|l| l.len() > MAX_POINTS)
|| d.series.len() > MAX_SERIES
|| d.series.iter().any(|s| {
trendlines += s.trendlines.len();
long(s.values.len())
|| !s.values.iter().all(is_clean)
|| s.x.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|| s.sizes.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|| s.trendlines.len() > MAX_TRENDLINES
})
|| trendlines > MAX_TRENDLINES
}
/// Enforces the chart's budget of points (`MAX_TOTAL_POINTS` = series x points: every series and
/// the category list keep their first `MAX_TOTAL_POINTS / series` points) and of trendlines
/// (`MAX_TRENDLINES` per chart, in series order).
fn apply_budget(mut d: ChartData) -> ChartData {
let cap = points_per_series(d.series.len());
d.categories.truncate(cap);
let mut trendlines = MAX_TRENDLINES;
for s in &mut d.series {
s.values.truncate(cap);
for v in [&mut s.x, &mut s.sizes].into_iter().flatten() {
v.truncate(cap);
}
s.trendlines.truncate(trendlines);
trendlines = trendlines.saturating_sub(s.trendlines.len());
}
d
}
/// The data `render` draws: `d` itself when it is already clean and within budget (the usual
/// case: no copy), else a cleaned, truncated copy.
pub(crate) fn sanitized(d: &ChartData) -> Cow<'_, ChartData> {
if needs_sanitizing(d) { Cow::Owned(apply_budget(sanitize(d))) } else { Cow::Borrowed(d) }
}
/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box.
pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec<Prim> {
if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 {
return vec![];
}
let data = sanitize(data);
let data = sanitized(data);
let mut ctx = Ctx { out: Vec::new(), m };
ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });
let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);
@@ -153,7 +239,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
use ChartKind::*;
match chart.kind {
Pie => pie(ctx, chart, data, area, 0.0, false),
Doughnut => pie(ctx, chart, data, area, 0.5, false),
Doughnut => doughnut(ctx, chart, data, area),
Sunburst => pie(ctx, chart, data, area, 0.2, true),
Treemap => treemap(ctx, chart, data, area),
Funnel => funnel(ctx, chart, data, area),
+135 -14
View File
@@ -3,8 +3,8 @@ use super::legend::{cat_label, plot_kind};
use super::lines::{decimate_area, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
VAlign, WHITE, clean, fixed_scale, nice_scale,
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, MIN_BAR_PX, Prim, Rgba, Scale,
SeriesData, TEXT, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
@@ -51,6 +51,110 @@ fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
/// Bars that fall into one pixel column, merged: extent along the category axis (`a0..=a1`, the
/// bars' start positions) and along the value axis (`lo..hi`).
struct BarRun {
bucket: f32,
a0: f32,
a1: f32,
lo: f32,
hi: f32,
/// More than one bar was merged.
merged: bool,
}
impl BarRun {
fn new(pos: f32, lo: f32, hi: f32) -> Self {
BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi, merged: false }
}
/// Whether `lo..hi` touches the run's value span; a disjoint bar would be joined by a filled gap.
fn touches(&self, lo: f32, hi: f32) -> bool {
lo <= self.hi && hi >= self.lo
}
fn add(&mut self, pos: f32, lo: f32, hi: f32) {
self.merged = true;
self.a0 = self.a0.min(pos);
self.a1 = self.a1.max(pos);
self.lo = self.lo.min(lo);
self.hi = self.hi.max(hi);
}
/// `band` is the part of the pixel column reserved for this series (`start`, `len`): in a
/// clustered chart a merged run stays inside it, so the other series keep a visible strip.
fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba, band: Option<(f32, f32)>) {
let (a0, along) = match band {
Some((start, len)) if self.merged => (self.bucket + start, len),
_ => (self.a0, self.a1 - self.a0 + bw),
};
if horizontal {
ctx.rect(self.lo, a0, self.hi - self.lo, along, color, None);
} else {
ctx.rect(a0, self.lo, along, self.hi - self.lo, color, None);
}
}
}
/// Whether bars of width `bw` are merged per pixel column (never when they carry data labels).
fn merge_hairlines(bw: f32, labels_on: bool) -> bool {
bw < MIN_BAR_PX && !labels_on
}
/// Draws the bars of one series: a rectangle each, or, for hairline bars, one merged [`BarRun`]
/// per pixel column.
struct BarSink {
run: Option<BarRun>,
bw: f32,
horizontal: bool,
color: Rgba,
nslots: f32,
k: f32,
}
impl BarSink {
fn new(bw: f32, horizontal: bool, color: Rgba, nslots: f32, k: f32) -> Self {
BarSink { run: None, bw, horizontal, color, nslots, k }
}
/// Start of this series' slot along the category axis, for the category centered at `c`.
fn pos(&self, c: f32) -> f32 {
if self.horizontal { c + self.nslots * self.bw / 2.0 - (self.k + 1.0) * self.bw } else { c - self.nslots * self.bw / 2.0 + self.k * self.bw }
}
/// This series' strip of a pixel column (offset, length) when the columns hold several
/// clustered series, in the direction the slots run.
fn band(&self) -> Option<(f32, f32)> {
(self.nslots > 1.0).then(|| {
let idx = if self.horizontal { self.nslots - 1.0 - self.k } else { self.k };
(idx / self.nslots, 1.0 / self.nslots)
})
}
/// Draws the bar at category center `c` spanning `lo..lo + len` on the value axis, or, when
/// `merge` is set, adds it to the current run.
fn bar(&mut self, ctx: &mut Ctx, merge: bool, c: f32, lo: f32, len: f32) {
let pos = self.pos(c);
let band = self.band();
if !merge {
if self.horizontal {
ctx.rect(lo, pos, len, self.bw, self.color, None);
} else {
ctx.rect(pos, lo, self.bw, len, self.color, None);
}
return;
}
match &mut self.run {
Some(m) if m.bucket == pos.floor() && m.touches(lo, lo + len) => m.add(pos, lo, lo + len),
run => {
if let Some(m) = run.replace(BarRun::new(pos, lo, lo + len)) {
m.draw(ctx, self.bw, self.horizontal, self.color, band);
}
}
}
}
fn finish(self, ctx: &mut Ctx) {
let band = self.band();
if let Some(m) = self.run {
m.draw(ctx, self.bw, self.horizontal, self.color, band);
}
}
}
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
@@ -96,8 +200,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
} else {
None
};
let mut row = Vec::with_capacity(ncat);
for j in 0..ncat {
// A series without values has no spans: 255 empty series over 20,000 categories would
// otherwise fill ~5 million entries.
let n = if s.values.is_empty() { 0 } else { ncat };
let mut row = Vec::with_capacity(n);
for j in 0..n {
let v = s.values.get(j).copied().flatten();
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
@@ -138,11 +245,12 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
let range = value_range(members.iter().flat_map(|i| {
let line = fams.get(*i).is_some_and(|f| is_line(*f));
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
// Lines contribute only their top value, bars both ends (no per-point `Vec`).
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| [*b, *t].into_iter().skip(usize::from(line)))
}));
let (lo, hi) = range.unwrap_or((0.0, 0.0));
let (scale, fmt) = if all100 {
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
(Scale { min: if lo < -1e-9 { -1.0 } else { 0.0 }, max: if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, step: 0.2 }, Fmt::new("0%"))
} else {
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
(nice_scale(lo, hi, force_zero), Fmt::new(code))
@@ -184,7 +292,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let kinds: Vec<ChartKind> = data.series.iter().enumerate().map(|(i, s)| if horizontal { chart.kind } else { plot_kind(chart, s, i) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
@@ -275,19 +383,16 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
1 => {
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
// Hairline bars are merged per pixel column (their envelope is what shows).
let cull = merge_hairlines(bw, labels_on);
let mut sink = BarSink::new(bw, horizontal, s.color, nslots, k);
for (j, sp) in row.iter().enumerate() {
let Some((b, t)) = sp else { continue };
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
let c = center(j);
let lo = pb.min(pt);
let len = (pb - pt).abs();
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
ctx.rect(lo, y, len, bw, s.color, None);
} else {
let x = c - nslots * bw / 2.0 + k * bw;
ctx.rect(x, lo, bw, len, s.color, None);
}
sink.bar(ctx, cull, c, lo, len);
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
let t = fmt.format(v, None);
let stacked = fam != Fam::Col;
@@ -313,6 +418,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
}
}
}
sink.finish(ctx);
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
@@ -450,3 +556,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
}
draw_trend_labels(ctx, plot, &trend_labels);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_series_get_no_span_entries() {
let empty = SeriesData::default();
let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() };
let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() };
let fams = [Fam::Col; 3];
let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS);
assert_eq!(spans.iter().map(Vec::len).collect::<Vec<_>>(), [0, MAX_CATS, 0]);
}
}
+161 -22
View File
@@ -1,8 +1,8 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, point_color};
use super::{
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, MAX_SERIES, MAX_WEDGES, Prim,
QuartileMethod, Rgba, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- histogram
@@ -163,6 +163,66 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
// ---------------------------------------------------------------- pie, doughnut, sunburst
/// At most `cap` of the largest values, in category order. When a ring has more categories than
/// its share of the wedge budget allows, the big slices survive rather than the first ones.
fn largest_slices(vals: &[f64], cap: usize) -> Vec<(usize, f64)> {
let mut idx: Vec<usize> = (0..vals.len()).collect();
if idx.len() > cap {
// Partition descends by value, so the first `cap` entries are the largest ones.
idx.select_nth_unstable_by(cap - 1, |a, b| vals[*b].total_cmp(&vals[*a]));
idx.truncate(cap);
idx.sort_unstable();
}
idx.into_iter().map(|j| (j, vals[j])).collect()
}
/// One ring's geometry and separator stroke: the slices of a ring differ only in angle and colour.
struct Ring {
cx: f32,
cy: f32,
outer: f32,
inner: f32,
stroke: Option<(Rgba, f32)>,
}
/// A wedge of `ring`, split at 12 o'clock when it crosses the end of the circle.
fn wedge(ctx: &mut Ctx, ring: &Ring, a0: f32, sweep: f32, fill: Rgba) {
let Ring { cx, cy, outer, inner, stroke } = *ring;
if sweep <= 1e-4 {
return;
}
// A slice that starts past 12 o'clock still has to be split against 12 o'clock, not against
// its unwrapped start: otherwise the half after TAU is drawn from 0 over its neighbours.
let a0 = a0.rem_euclid(TAU);
let a1 = a0 + sweep;
if a1 <= TAU {
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1, fill, stroke });
return;
}
// The halves are filled without a stroke: each would outline its own radial edge at 12
// o'clock, painting the white separator straight through the slice.
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1: TAU, fill, stroke: None });
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0: 0.0, a1: a1 - TAU, fill, stroke: None });
if let Some((color, width)) = stroke {
// One outline for the whole slice: the outer and inner arcs run through 12 o'clock, and
// the two radial edges at the slice's own start and end close the path.
let arc = |r: f32, from: f32, to: f32| -> Vec<[f32; 2]> {
let steps = ((r.abs() * (to - from).abs() / 2.0).ceil() as usize).clamp(4, 512);
let at = |a: f32| [cx + r * a.sin(), cy - r * a.cos()];
(0..=steps).map(|k| at(from + (to - from) * k as f32 / steps as f32)).collect()
};
let mut pts = arc(outer, a0, a1);
if inner > 0.0 {
pts.extend(arc(inner, a1, a0));
} else {
pts.push([cx, cy]);
}
let start = pts[0];
pts.push(start);
ctx.out.push(Prim::Line { pts, color, width, dash: false });
}
}
pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
@@ -176,14 +236,15 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
return;
}
let ri = r * inner;
let ring = Ring { cx, cy, outer: r, inner: ri, stroke: Some((WHITE, 1.5)) };
let f = Fmt::new(&s.number_format);
let mut a = 0.0f32;
let mut a = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
let mut labels = Vec::new();
for (j, v) in vals.iter().enumerate() {
let sweep = (*v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
wedge(ctx, &ring, a, sweep, fill);
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
let mid = a + sweep / 2.0;
@@ -201,21 +262,97 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
}
}
/// Excel's doughnut: every series is one concentric ring, the first series innermost (measured in
/// Excel: ring outer radii ≈0.83, 0.92 and 1.0 of the plot radius for series 1, 2 and 3). The hole
/// size and first slice angle come from the chart; each ring is scaled to its own total.
pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let nser = data.series.len().min(MAX_SERIES);
if nser == 0 {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let r = area.w.min(area.h) / 2.0 * 0.92;
if r < 2.0 {
return;
}
let hole = f32::from(chart.hole_size.clamp(10, 90)) / 100.0;
let ri = r * hole;
let thick = (r - ri) / nser as f32;
// A ring thinner than a pixel would vanish. Rather than suppressing the whole chart when there
// are more series than the annulus has pixels, keep the outer rings at one pixel each and drop
// the innermost series that no longer fit: Excel's order makes the first series the innermost,
// so the outermost rings are the ones worth keeping.
let band = thick.max(1.0);
let start = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
// Only rings with a pixel of band are drawn, and the wedge budget is shared across them in
// drawing order: the innermost ring (the first series) gets its slices first, and the outer
// rings are capped instead of the series × category product growing without bound.
let rings: Vec<(&SeriesData, f32, f32)> = data
.series
.iter()
.take(nser)
.enumerate()
.filter_map(|(si, s)| {
let outer = (r - (nser - 1 - si) as f32 * band).min(r);
let inner = (r - (nser - si) as f32 * band).max(ri);
(outer - inner >= 1.0).then_some((s, outer, inner))
})
.collect();
let mut budget = MAX_WEDGES;
let mut labels = Vec::new();
for (k, (s, outer, inner)) in rings.iter().enumerate() {
// The white separator is 1.5 units wide, wider than a one-pixel band: on a thin ring it
// would paint over the fill of this ring and its neighbour, so such rings keep only fill.
let stroke = if outer - inner > 1.0 { Some((WHITE, 1.5)) } else { None };
let ring = Ring { cx, cy, outer: *outer, inner: *inner, stroke };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
let cap = (budget / (rings.len() - k)).max(1);
let slices = largest_slices(&vals, cap);
let total: f64 = slices.iter().map(|(_, v)| *v).sum();
if !(total.is_finite() && total > 0.0) {
continue;
}
let n = slices.len();
budget = budget.saturating_sub(n);
let f = Fmt::new(&s.number_format);
let mut a = start;
for (j, v) in slices {
let sweep = (v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
wedge(ctx, &ring, a, sweep, fill);
if chart.data_labels && n <= MAX_LABELS {
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let mid = a + sweep / 2.0;
let rr = (outer + inner) / 2.0;
if ctx.tw(&t, LABEL_SIZE) <= (sweep * rr).max(outer - inner) && sweep * rr >= LABEL_SIZE {
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
}
}
}
a += sweep;
}
}
for (x, y, t, c) in labels {
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
}
}
// ---------------------------------------------------------------- treemap
fn worst(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
/// Worst aspect ratio of a row with the given area sum, smallest and largest item, laid along `side`.
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
}
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
/// Squarified treemap layout of (index, value) items, sorted descending, into `r`; each block
/// comes back with its index and value.
pub(super) fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, f64, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
@@ -224,9 +361,10 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
let mut row: Vec<(usize, f64, f64)> = Vec::new();
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let layout = |row: &[(usize, f64, f64)], rem: &mut Bx, out: &mut Vec<(usize, f64, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
@@ -234,9 +372,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
for (j, a, v) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
out.push((*j, *v, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
@@ -244,9 +382,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
for (j, a, v) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
out.push((*j, *v, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
@@ -256,15 +394,17 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
row.push((j, a));
// Sum, smallest and largest item of the open row, kept incrementally (same fold order as
// summing the row afresh, so the numbers are bit-identical).
let with = (sum + a, mn.min(a), mx.max(a));
if row.is_empty() || worst(with.0, with.1, with.2, side) <= worst(sum, mn, mx, side) {
row.push((j, a, v));
(sum, mn, mx) = with;
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
(sum, mn, mx) = (0.0, f64::INFINITY, 0.0);
}
}
if !row.is_empty() {
@@ -279,14 +419,13 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
items.sort_by(|a, b| b.1.total_cmp(&a.1));
let f = Fmt::new(&s.number_format);
for (j, b) in squarify(&items, area) {
for (j, v, b) in squarify(&items, area) {
let fill = point_color(data, j);
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
}
+21 -8
View File
@@ -15,9 +15,10 @@ pub enum Swatch {
Marker,
}
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule
/// as `series_plot_kind`, applied to the already resolved series kind.
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind {
crate::resolve::plot_kind(chart.kind, Some(s.kind), i)
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
@@ -42,6 +43,8 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
return vec![];
}
match chart.kind {
// Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours
// and the legend names categories rather than series), treemap and sunburst.
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
let n = first_len(data).min(MAX_CATS);
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
@@ -58,8 +61,9 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
.enumerate()
.map(|(i, s)| {
let sw = match plot_kind(chart, s, i) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
@@ -69,7 +73,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
@@ -81,7 +85,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
pub(super) fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
@@ -145,7 +149,16 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
}
LegendPos::Left | LegendPos::Right => {
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
// Widest entry, capped at 35% of the area: once the cap is reached the rest cannot change it.
let cap = area.w * 0.35;
let mut colw = 0.0f32;
for e in entries {
if colw >= cap {
break;
}
colw = colw.max(item_w(ctx, e));
}
let colw = colw.min(cap);
let max_rows = ((area.h / row_h).floor() as usize).max(1);
let n = entries.len().min(max_rows);
let lh = n as f32 * row_h;
+34 -6
View File
@@ -136,7 +136,13 @@ pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize)
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
///
/// The points of the smoothed runs (data points included) are budgeted across all the runs of the line, not per run:
/// short runs between gaps escape decimation, and 100 sub-segments each would turn a
/// 100,000-point series of three-point runs into millions of points. Past the budget the
/// remaining runs are drawn through their data points only.
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut budget = MAX_LINE_PTS * 2;
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
@@ -145,7 +151,11 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = match smooth {
Some(plot) => smooth_run(r, plot),
Some(plot) => {
let r = smooth_run(r, plot, budget);
budget = budget.saturating_sub(r.len());
r
}
None => r,
};
ctx.line(r, color, width);
@@ -158,15 +168,15 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi
}
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
/// 100), chosen so the whole curve stays within `cap` points (one per data point when `cap` is
/// smaller than that). Endpoints are
/// duplicated so the curve passes through the first and last points, and the curve is clamped
/// to the `plot` box (a spline overshoots its data near sharp turns).
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx, cap: usize) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
let cap = MAX_LINE_PTS * 2;
let steps = (cap / n).clamp(1, 100);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
let at = |i: isize| -> [f32; 2] {
@@ -219,7 +229,7 @@ mod smooth_tests {
fn long_runs_are_smoothed_uniformly_within_the_cap() {
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
let out = smooth_run(pts, plot);
let out = smooth_run(pts, plot, MAX_LINE_PTS * 2);
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
assert_eq!(out.len(), 1499 * 2 + 1);
@@ -231,11 +241,29 @@ mod smooth_tests {
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
// A step right at the plot edges: Catmull-Rom overshoots past both.
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
let out = smooth_run(pts, plot);
let out = smooth_run(pts, plot, MAX_LINE_PTS * 2);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
/// Security: smoothing budgets its added points across every run of a line. A series of
/// three-point runs between gaps used to get 100 sub-segments per run, 67 times its data.
#[test]
fn gap_separated_runs_share_one_smoothing_budget() {
let plot = Bx { x: 0.0, y: 0.0, w: 1e6, h: 100.0 };
let pts: Vec<Option<[f32; 2]>> =
(0..100_000).map(|i| if i % 4 == 3 { None } else { Some([i as f32, if i % 2 == 0 { 20.0 } else { 80.0 }]) }).collect();
let m = crate::ApproxMeasure;
let mut ctx = Ctx { out: Vec::new(), m: &m };
draw_runs(&mut ctx, &pts, WHITE, 1.0, Some(plot));
let total: usize = ctx.out.iter().map(|p| if let Prim::Line { pts, .. } = p { pts.len() } else { 0 }).sum();
// 75,000 data points, plus at most the budget added by interpolation.
assert!(total <= 75_000 + MAX_LINE_PTS * 2, "{total}");
assert_eq!(ctx.out.len(), 25_000);
// The first runs are still smoothed.
assert!(matches!(ctx.out.first(), Some(Prim::Line { pts, .. }) if pts.len() > 3));
}
#[test]
fn decimate_area_preserves_paired_vertical_strips() {
let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect();
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, lay
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
if settings.show_keys {
let swatch = match plot_kind(chart, series) {
let swatch = match plot_kind(chart, series, i) {
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
_ => Swatch::Box,
+298
View File
@@ -0,0 +1,298 @@
//! Equality and counter tests for the label-fitting, legend-measuring and treemap rewrites.
//! The old implementations live here as references.
use std::cell::Cell;
use super::kinds::squarify;
use super::legend::swatch_w;
use super::*;
use crate::{ApproxMeasure, Measure};
use gridcraft_model::LegendPos;
/// The pre-rewrite `Ctx::fit`: one `Measure` call per candidate length, longest first.
fn fit_reference(ctx: &Ctx, s: &str, size: f32, maxw: f32) -> String {
if ctx.tw(s, size) <= maxw {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len().min(400);
while n > 0 {
n -= 1;
let t: String = chars.iter().take(n).collect::<String>() + "…";
if ctx.tw(&t, size) <= maxw {
return if n == 0 { String::new() } else { t };
}
}
String::new()
}
/// Counts calls; advances differ per character (wide CJK/emoji, zero-width combining marks) and
/// "AV"-style pairs are kerned tighter, but a character never makes the text narrower.
struct Varied {
calls: Cell<usize>,
}
impl Measure for Varied {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
self.calls.set(self.calls.get() + 1);
let mut w = 0.0f32;
let mut prev = ' ';
for c in text.chars() {
let adv = match c as u32 {
0x300..=0x36f => 0.0,
0x1f300..=0x1faff | 0x4e00..=0x9fff => 1.0,
u => 0.3 + (u % 7) as f32 * 0.07,
};
let kern = if prev == 'A' && c == 'V' { 0.1 } else { 0.0 };
w += (adv - kern) * size;
prev = c;
}
if bold { w * 1.1 } else { w }
}
}
struct Counting<'a>(&'a dyn Measure, Cell<usize>);
impl Measure for Counting<'_> {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
self.1.set(self.1.get() + 1);
self.0.text_width(text, size, bold)
}
}
/// Like a text layout that breaks at `\n`: the width is that of the widest line. Adding a
/// newline can make the text narrower, so the width of `prefix + "…"` is not monotone.
struct WidestLine;
impl Measure for WidestLine {
fn text_width(&self, text: &str, size: f32, _bold: bool) -> f32 {
text.split('\n')
.map(|line| {
line.chars()
.map(|c| {
size * if c == 'i' {
0.25
} else if c == 'M' {
0.9
} else {
0.5
}
})
.sum::<f32>()
})
.fold(0.0, f32::max)
}
}
fn corpus() -> Vec<String> {
let mut v: Vec<String> =
["", "a", "ab", "Hello, world", "AVAVAV tight", "日本語のラベルです", "👨‍👩‍👧 family 🎉 party", "e\u{301}e\u{301}e\u{301} combining", "…", "……"]
.iter()
.map(|s| s.to_string())
.collect();
v.push("x".repeat(399));
v.push("x".repeat(400));
v.push("x".repeat(401));
v.push("é🎉".repeat(500));
v.push("long label ".repeat(100));
for s in ["iiii\nMM", "iiii\n", "\niiii", "ab\ncd\nef", "iiiiiiii\niiiiMMMM\nMM", "line one\nline two is longer\nx"] {
v.push(s.to_string());
}
v
}
fn run_fit(m: &dyn Measure) {
let ctx = Ctx { out: vec![], m };
for s in corpus() {
for size in [9.0f32, 11.0, 40.0] {
for maxw in [-5.0, 0.0, 0.4, 6.0, 25.0, 61.5, 120.0, 800.0, 1e6, f32::INFINITY, f32::NAN] {
assert_eq!(ctx.fit(&s, size, maxw), fit_reference(&ctx, &s, size, maxw), "{s:?} size {size} maxw {maxw}");
}
}
}
}
#[test]
fn fit_matches_linear_reference() {
run_fit(&ApproxMeasure);
run_fit(&Varied { calls: Cell::new(0) });
run_fit(&WidestLine);
}
#[test]
fn a_multiline_label_keeps_its_fitting_prefix() {
// The whole label is 18 wide and "iii…" is 12.5, both too wide for 10.5, but "iiii\n…" is
// 10 wide and fits. A binary search that stops at the failing midpoint would return nothing.
let ctx = Ctx { out: vec![], m: &WidestLine };
let s = "iiii\nMM";
let size = 10.0;
let maxw = 10.5;
assert!(ctx.tw(s, size) > maxw);
assert!(ctx.tw("iii…", size) > maxw && ctx.tw("iiii\n…", size) <= maxw);
assert_eq!(ctx.fit(s, size, maxw), "iiii\n…");
}
#[test]
fn fit_measures_logarithmically() {
let long = "x".repeat(1000);
let c = Counting(&ApproxMeasure, Cell::new(0));
let ctx = Ctx { out: vec![], m: &c };
let r = ctx.fit(&long, 11.0, 60.0);
assert!(r.ends_with('…') && r.chars().count() > 3);
// 1 whole-string check + ceil(log2(399)) + 1 probes; the old scan needed ~390 here.
assert!(c.1.get() <= 12, "{} Measure calls", c.1.get());
let c2 = Counting(&ApproxMeasure, Cell::new(0));
let ctx2 = Ctx { out: vec![], m: &c2 };
assert_eq!(ctx2.fit("short", 11.0, 500.0), "short");
assert_eq!(c2.1.get(), 1);
}
fn legend_entries(n: usize, label: &dyn Fn(usize) -> String) -> Vec<(String, Rgba, Swatch)> {
(0..n).map(|i| (label(i), [10, 20, 30, 255], if i % 3 == 0 { Swatch::Line } else { Swatch::Box })).collect()
}
/// Colour column width as the old code computed it, from the returned remaining area.
fn old_colw(m: &dyn Measure, entries: &[(String, Rgba, Swatch)], area: Bx) -> f32 {
let ctx = Ctx { out: vec![], m };
let size = LABEL_SIZE;
let s = size * 0.8;
entries.iter().map(|e| swatch_w(e.2, s) + 4.0 + ctx.tw(&e.0, size)).fold(0.0f32, f32::max).min(area.w * 0.35)
}
#[test]
fn side_legend_matches_old_column_width_and_stops_measuring_at_the_cap() {
let area = Bx { x: 0.0, y: 0.0, w: 400.0, h: 300.0 };
let labels: [&dyn Fn(usize) -> String; 3] =
[&|i| format!("entry {i}"), &|i| if i == 7 { "w".repeat(80) } else { format!("e{i}") }, &|i| "very long legend label ".repeat(1 + i % 3)];
for pos in [LegendPos::Left, LegendPos::Right] {
for (k, label) in labels.iter().enumerate() {
for n in [0usize, 1, 5, 40] {
let entries = legend_entries(n, label);
let mut ctx = Ctx { out: vec![], m: &ApproxMeasure };
let rest = draw_legend(&mut ctx, &entries, pos, area);
let colw = old_colw(&ApproxMeasure, &entries, area);
assert!((rest.w - (area.w - (colw + 8.0))).abs() < 1e-4, "{pos:?} labels {k} n {n}");
}
}
}
// 20,000 entries whose first one already fills the 35% cap: the old code measured all of them
// plus the drawn rows, now only the first entry and the drawn rows are measured.
let entries = legend_entries(20_000, &|i| if i == 0 { "w".repeat(200) } else { format!("e{i}") });
let c = Counting(&ApproxMeasure, Cell::new(0));
let mut ctx = Ctx { out: vec![], m: &c };
let _ = draw_legend(&mut ctx, &entries, LegendPos::Right, area);
let drawn = ctx.out.iter().filter(|p| matches!(p, Prim::Text { .. })).count();
assert!(c.1.get() <= drawn * 10 + 5, "{} Measure calls for {drawn} drawn entries", c.1.get());
assert!(c.1.get() < 500);
}
/// The pre-rewrite `worst`/`squarify` (a `Vec` per item, row recomputed from scratch).
fn worst_reference(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
}
fn squarify_reference(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
return vec![];
}
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
}
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
rem.w = (rem.w - cw).max(0.0);
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
rem.h = (rem.h - rh).max(0.0);
}
};
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst_reference(&with, side) <= worst_reference(&cur, side) {
row.push((j, a));
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
}
}
if !row.is_empty() {
layout(&row, &mut rem, &mut out);
}
out
}
#[test]
fn squarify_matches_reference() {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let mut cases: Vec<Vec<f64>> = vec![
vec![],
vec![5.0],
vec![6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0],
vec![3.0, 0.0, 0.0, 1.0],
vec![0.0, 0.0],
vec![4.0, -1.0, 2.0],
vec![4.0, f64::NAN, 2.0, 1.0],
vec![f64::INFINITY, 1.0],
vec![1e-300, 1e-310, 0.0],
];
for n in [2usize, 9, 40, 300] {
let mut v: Vec<f64> = (0..n).map(|_| rnd() * 100.0).collect();
v.sort_by(|a, b| b.total_cmp(a));
cases.push(v);
}
for r in [Bx { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }, Bx { x: 5.0, y: 7.0, w: 90.0, h: 400.0 }, Bx { x: 0.0, y: 0.0, w: 0.0, h: 50.0 }] {
for vals in &cases {
let items: Vec<(usize, f64)> = vals.iter().copied().enumerate().collect();
let new: Vec<(usize, Bx)> = squarify(&items, r)
.into_iter()
.map(|(j, v, b)| {
assert!(vals.get(j).is_some_and(|x| x.to_bits() == v.to_bits()), "value carried through");
(j, b)
})
.collect();
let old = squarify_reference(&items, r);
assert_eq!(format!("{new:?}"), format!("{old:?}"), "{vals:?} in {r:?}");
}
}
}
+282 -38
View File
@@ -1,7 +1,7 @@
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
use gridcraft_calc::evaluate;
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, category_levels, number_to_text};
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
use gridcraft_numfmt::{NumberFormat, format_value};
@@ -11,8 +11,17 @@ use crate::{ChartData, Rgba, SeriesData, TrendlineData};
pub(crate) const MAX_POINTS: usize = 100_000;
/// Cap on series per chart.
pub(crate) const MAX_SERIES: usize = 255;
/// Cap on trendlines drawn per series.
/// Cap on trendlines per chart (not per series: each one is a fit over a series' points).
pub(crate) const MAX_TRENDLINES: usize = 16;
/// Budget for series x points in one chart. When a chart would hold more, every series (and the
/// category list) keeps its first `MAX_TOTAL_POINTS / series` points; no series is dropped.
pub(crate) const MAX_TOTAL_POINTS: usize = 2_000_000;
/// Points kept per series (and categories) in a chart of `series` series: `MAX_POINTS`, or less
/// when `series x MAX_POINTS` would pass `MAX_TOTAL_POINTS`.
pub(crate) fn points_per_series(series: usize) -> usize {
(MAX_TOTAL_POINTS / series.clamp(1, MAX_SERIES)).clamp(1, MAX_POINTS)
}
/// 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];
@@ -77,10 +86,51 @@ fn union_parts(f: &str) -> Option<Vec<&str>> {
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0)
eval_list_in(wb, sheet, formula, MAX_POINTS, 0)
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
/// calc reads a reference of up to this many cells at its full size and trims a larger one to the
/// sheet's used range (`FULL_SIZE_CELLS` in `gridcraft_calc::eval`). A clipped reference must stay
/// above it, or the clip would turn a trimmed read into a full-size one with trailing blanks.
const CALC_FULL_SIZE_CELLS: u64 = 16_384;
/// Bounded references up to this many cells are read at their full size too
/// (`FULL_SIZE_BOUNDED_CELLS` in `gridcraft_calc::eval`).
const CALC_FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
/// `formula` as a plain reference (`Sheet!A1:A1000000`) cut after the rows that can still hold
/// one of the first `limit` points, so that calc never reads the rest; `None` when `formula` is
/// no reference or already small enough. Values are read row by row, and calc trims only the
/// bottom and right of a reference, so keeping `limit` rows keeps the first `limit` values.
fn clipped_reference(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Option<String> {
let (sh, mut r) = parse_ref(wb, sheet, formula)?;
let cells = u64::from(r.height()).saturating_mul(u64::from(r.width()));
// calc trims a block that runs to the sheet's edge, and any block over a column's worth, to
// the used range. Do it here, or the clipped block below would be read at its full size.
let to_edge = r.end.row == MAX_ROWS - 1 || r.end.col == MAX_COLS - 1;
let trimmed = cells > CALC_FULL_SIZE_CELLS && (to_edge || cells > CALC_FULL_SIZE_BOUNDED_CELLS);
let mut changed = false;
if trimmed {
let end = match wb.sheet(sh).and_then(|s| s.used_range()) {
Some(u) => CellRef::new(r.end.row.min(u.end.row.max(r.start.row)), r.end.col.min(u.end.col.max(r.start.col))),
None => r.start,
};
changed = end != r.end;
r = RangeRef::new(r.start, end);
}
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
let (h, w) = (u64::from(r.height()), u64::from(r.width()));
if h.saturating_mul(w) > CALC_FULL_SIZE_CELLS {
let keep = (limit as u64).max(CALC_FULL_SIZE_CELLS / w + 1);
if h > keep {
let end = CellRef::new(r.start.row.saturating_add(u32::try_from(keep).ok()?).saturating_sub(1), r.end.col);
r = RangeRef::new(r.start, end);
changed = true;
}
}
changed.then(|| format!("{prefix}{r}"))
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
@@ -90,16 +140,17 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<V
{
let mut out = Vec::new();
for p in parts {
if out.len() >= MAX_POINTS {
if out.len() >= limit {
break;
}
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
let rest = limit - out.len();
out.extend(eval_list_in(wb, sheet, p, rest, depth + 1).into_iter().take(rest));
}
return out;
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
let clipped = clipped_reference(wb, sheet, f, limit);
match evaluate(wb, sheet, CellRef::default(), clipped.as_deref().unwrap_or(f)) {
Value::Array(a) => a.iter().take(limit).cloned().collect(),
v => vec![v],
}
}
@@ -111,8 +162,8 @@ fn to_num(v: &Value) -> Option<f64> {
}
}
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
eval_list(wb, sheet, formula).iter().map(to_num).collect()
fn nums(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Vec<Option<f64>> {
eval_list_in(wb, sheet, formula, limit, 0).iter().map(to_num).collect()
}
/// Number format code of the first cell of a reference ("General" when not a reference).
@@ -133,38 +184,113 @@ fn value_text(v: &Value) -> String {
}
}
/// Characters kept of one category label: an axis label never shows more (labels are fitted to
/// the axis), and copying more only costs memory.
const MAX_LABEL_CHARS: usize = 255;
/// Bytes of category label text kept per series. One long text broadcast by a short formula
/// (`IF(SEQUENCE(100000), long_text)`) is a single shared value, but turning every element into
/// its own label copied it 100,000 times (gigabytes). Labels past the budget are empty.
const MAX_CATEGORY_BYTES: usize = 8 << 20;
/// At most [`MAX_LABEL_CHARS`] characters and `room` bytes of `s`, cut at a character boundary.
fn clip_label(s: &str, room: usize) -> String {
let mut end = 0;
for (n, (i, c)) in s.char_indices().enumerate() {
if n >= MAX_LABEL_CHARS || i + c.len_utf8() > room {
break;
}
end = i + c.len_utf8();
}
s.get(..end).unwrap_or_default().to_string()
}
/// The label of category `i` (value `v`) in at most `room` bytes: formatted with its source
/// cell's number format when it is a number.
fn category_label(wb: &Workbook, src: Option<(&gridcraft_model::Sheet, RangeRef)>, i: usize, v: &Value, room: usize) -> String {
if room == 0 {
return String::new(); // nothing fits: skip the number formatting
}
if let (Some((s, r)), Value::Number(_)) = (src, v) {
let w = r.width().max(1) as usize;
let at = r.start.offset((i / w) as i64, (i % w) as i64);
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 clip_label(&format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text, room);
}
}
}
// Text is clipped before it is copied: the source may be one long shared string.
let mut v = v;
while let Value::Array(a) = v {
v = a.get(0, 0).unwrap_or(&Value::Empty);
}
match v {
Value::Text(t) => clip_label(t, room),
_ => clip_label(&value_text(v), room),
}
}
/// Category labels, formatted with each source cell's number format (dates, currency…), and
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
/// has one entry per category, non-empty where a group starts, as the cells are.
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
let vals = eval_list(wb, sheet, formula);
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limit: usize) -> (Vec<String>, Vec<Vec<String>>) {
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
// Bound the category dimension by `limit` and read only the cells the levels use (the
// innermost label line and up to three outer ones), so that every kept category keeps its
// cells in every level. Counting raw cells would cut a two-level axis in the middle.
let levels = cells.and_then(|(_, r)| category_levels(&r, value_range.as_ref()));
let (cells, read) = match (cells, levels) {
(Some((sh, r)), Some(lv)) => {
let cut = u32::try_from(limit.saturating_sub(1)).unwrap_or(u32::MAX);
let outer = lv.outer;
let end = if lv.by_columns {
CellRef::new(r.end.row.min(r.start.row.saturating_add(cut)), r.end.col)
} else {
CellRef::new(r.end.row, r.end.col.min(r.start.col.saturating_add(cut)))
};
let start = if lv.by_columns {
CellRef::new(r.start.row, r.start.col.max(end.col.saturating_sub(outer)))
} else {
CellRef::new(r.start.row.max(end.row.saturating_sub(outer)), r.start.col)
};
let r = RangeRef::new(start, end);
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
let cap = (r.width() as usize).saturating_mul(r.height() as usize);
(Some((sh, r)), Some((format!("{prefix}{r}"), cap)))
}
(c, _) => (c, None),
};
let vals = match &read {
Some((f, cap)) => eval_list_in(wb, sheet, f, *cap, 0),
None => eval_list_in(wb, sheet, formula, limit, 0),
};
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
// The retained levels are known before any label is charged: a cell on a discarded level
// gets no label and spends none of the budget. `None`: a flat list (every cell is a label).
let layout = src.and_then(|(_, r)| {
let levels = levels?;
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
(vals.len() == w.saturating_mul(h)).then_some((levels, w, h)) // else truncated or not a plain block
});
let kept =
|i: usize| layout.is_none_or(|(l, w, h)| if l.by_columns { i % w + 1 + l.outer as usize >= w } else { i / w + 1 + l.outer as usize >= h });
let mut room = MAX_CATEGORY_BYTES;
let texts: Vec<String> = vals
.iter()
.enumerate()
.map(|(i, v)| {
if let (Some((s, r)), Value::Number(_)) = (src, v) {
let w = r.width().max(1) as usize;
let at = r.start.offset((i / w) as i64, (i % w) as i64);
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_cached(code), wb.date_system).text;
}
}
if !kept(i) {
return String::new();
}
value_text(v)
let label = category_label(wb, src, i, v, room);
room = room.saturating_sub(label.len());
label
})
.collect();
let Some((_, r)) = src else { return (texts, vec![]) };
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
if texts.len() != w.saturating_mul(h) {
return (texts, vec![]); // truncated or not a plain block: a flat list
}
let Some((levels, w, h)) = layout else { return (texts, vec![]) };
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
@@ -201,7 +327,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
t.to_string()
}
fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
match (chart_kind, series_kind) {
(ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k,
(ChartKind::Combo, _) => {
@@ -216,9 +342,15 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usiz
}
}
/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the
/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing.
pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind }
}
/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind }
plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i)
}
/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
@@ -228,37 +360,43 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
let mut series = Vec::new();
let mut categories: Option<Vec<String>> = None;
let limit = points_per_series(chart.series.len());
let mut trend_left = MAX_TRENDLINES;
let mut category_levels = Vec::new();
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
let values = nums(wb, sheet, &s.values);
let values = nums(wb, sheet, &s.values, limit);
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
let color =
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
let kind = effective_kind(chart.kind, s.kind, i);
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
// X values with text in them (labels) are ignored: the points are numbered 1..n.
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
(Some(f), true) => {
Some(eval_list_in(wb, sheet, f, limit, 0)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())
}
_ => None,
};
if categories.is_none()
&& let Some(f) = &s.categories
{
let (c, outer) = category_table(wb, sheet, f, &s.values);
let (mut c, outer) = category_table(wb, sheet, f, &s.values, limit);
c.truncate(limit);
if !c.is_empty() {
categories = Some(c);
category_levels = outer;
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, limit));
let trendlines = s
.trendlines
.iter()
.take(MAX_TRENDLINES)
.take(trend_left)
.map(|t| TrendlineData {
spec: t.clone(),
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
})
.collect();
.collect::<Vec<_>>();
trend_left = trend_left.saturating_sub(trendlines.len());
series.push(SeriesData {
name,
values,
@@ -277,3 +415,109 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, category_levels, series, palette }
}
#[cfg(test)]
mod label_budget_tests {
use gridcraft_model::Workbook;
/// Security: category labels are clipped before they are copied, with one byte budget per
/// series. A formula broadcasting one long text to 100,000 categories used to copy it
/// 100,000 times (3.3 GB).
#[test]
fn category_labels_share_one_byte_budget() {
let wb = Workbook::new();
let (texts, _) = super::category_table(&wb, 0, r#"=IF(SEQUENCE(100000),REPT("x",32767))"#, "", super::MAX_POINTS);
assert_eq!(texts.len(), 100_000);
assert_eq!(texts.first().map(String::len), Some(super::MAX_LABEL_CHARS));
assert!(texts.iter().map(String::len).sum::<usize>() <= super::MAX_CATEGORY_BYTES);
// Labels are cut at character boundaries.
assert_eq!(super::clip_label("aé", 2), "a");
assert_eq!(super::clip_label("aé", 3), "aé");
}
/// Cells on discarded outer levels (only the innermost labels and three outer levels are
/// kept) spend none of the byte budget.
#[test]
fn discarded_levels_do_not_spend_the_label_budget() {
let mut wb = Workbook::new();
let rows = 8000u32;
if let Some(sh) = wb.sheet_mut(0) {
for r in 0..rows {
for c in 0..5 {
sh.set_value(gridcraft_core::CellRef::new(r, c), gridcraft_core::Value::text("x".repeat(255)));
}
}
}
// Five columns: column A is a fourth outer level and is discarded.
let (inner, outer) = super::category_table(&wb, 0, "=A1:E8000", "", super::MAX_POINTS);
assert_eq!(outer.len(), 3);
assert_eq!(inner.last().map(String::len), Some(super::MAX_LABEL_CHARS));
assert_eq!(outer.first().and_then(|l| l.last()).map(String::len), Some(super::MAX_LABEL_CHARS));
}
}
#[cfg(test)]
mod tests {
use gridcraft_model::Workbook;
use super::*;
fn book(rows: u32, cols: u32) -> Workbook {
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
s.name = "My Sheet".into();
for r in 0..rows {
for c in 0..cols {
s.set_value(CellRef::new(r, c), Value::Number(f64::from(r * 10 + c)));
}
}
}
wb
}
#[test]
fn a_long_reference_is_clipped_before_it_is_evaluated() {
let wb = book(120_000, 3);
let rows = |f: &str, limit| {
let g = clipped_reference(&wb, 0, f, limit)?;
let (_, r) = parse_ref(&wb, 0, &g)?;
Some((g, u64::from(r.height())))
};
// 1,000,000 rows: at most `MAX_POINTS` of them are asked for.
assert_eq!(rows("'My Sheet'!$A$1:$A$1000000", MAX_POINTS), Some(("'My Sheet'!A1:A100000".to_string(), 100_000)));
assert_eq!(rows("A5:A1048576", MAX_POINTS).map(|r| r.1), Some(100_000));
assert_eq!(rows("A:A", 50_000).map(|r| r.1), Some(50_000));
// Never below what keeps calc trimming the clipped reference like the whole one.
assert_eq!(rows("A1:A1000000", 100).map(|r| r.1), Some(16_385));
// Wide references keep whole rows.
assert_eq!(rows("A1:C1000000", MAX_POINTS).map(|r| r.1), Some(100_000));
// Small references, single cells and anything that isn't a plain reference are left alone.
assert_eq!(rows("A1:A9000", MAX_POINTS), None);
assert_eq!(rows("A1:A20000", MAX_POINTS), None);
assert_eq!(rows("B2", MAX_POINTS), None);
assert_eq!(rows("SUM(A1:A1000000)", MAX_POINTS), None);
assert_eq!(rows("{1,2,3}", MAX_POINTS), None);
}
#[test]
fn clipping_does_not_change_the_points() {
for (rows, cols) in [(10, 1), (30_000, 1), (30_000, 2), (3, 4)] {
let wb = book(rows, cols);
for f in ["'My Sheet'!A1:A1000000", "'My Sheet'!$A$3:$B$1048576", "A:A", "A1:C1000000", "(A1:A1000000,B1:B1000000)", "A2:A20000"] {
for limit in [100, 7_843, 25_000, MAX_POINTS] {
let clipped = eval_list_in(&wb, 0, f, limit, 0);
let whole: Vec<Value> = match evaluate(&wb, 0, CellRef::default(), f) {
Value::Array(a) => a.iter().take(limit).cloned().collect(),
v => vec![v],
};
if f.starts_with('(') {
// A union is read part by part; each part keeps its own order.
assert!(clipped.len() <= limit);
continue;
}
assert_eq!(clipped, whole, "{f} limit {limit} on {rows}x{cols}");
}
}
}
}
}
+351 -1
View File
@@ -5,6 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
mod budget;
mod data_table;
const ALL_KINDS: [ChartKind; 26] = [
@@ -48,6 +49,8 @@ fn chart(kind: ChartKind) -> Chart {
data_table: None,
gridlines: true,
style: 0,
hole_size: 75,
first_slice_angle: 0,
x_title: Some("Quarter".into()),
y_title: Some("Revenue".into()),
source: None,
@@ -149,6 +152,317 @@ fn every_kind_renders_sanely() {
}
}
#[test]
fn doughnut_draws_each_series_as_a_ring() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 25;
c.first_slice_angle = 0;
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B", "C"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Inner", &[1.0, 2.0, 3.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Middle", &[3.0, 2.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
series("Outer", &[1.0, 1.0, 1.0], [89, 161, 79, 255], ChartKind::Doughnut),
],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let wedges: Vec<(f32, f32, f32)> = prims
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, r_inner, a0, .. } => Some((*r_outer, *r_inner, *a0)),
_ => None,
})
.collect();
assert_eq!(wedges.len(), 9, "three rings of three slices");
// Excel draws the first series as the innermost ring; the hole is 25% of the radius.
assert!(wedges[0].0 < wedges[3].0 && wedges[3].0 < wedges[6].0, "{wedges:?}");
assert!(wedges[0].1 > 0.0, "hole: {wedges:?}");
}
#[test]
fn doughnut_with_many_series_still_draws_the_outer_rings() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
// The annulus is a few dozen pixels tall and there are far more series than pixels, so every
// ring is thinner than 1 px. The chart used to draw nothing at all.
let many = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
};
let single = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
// Two series, so the title fallback (a lone series names the chart) does not shrink the plot.
series: vec![
series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Other", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let (w, h) = (640.0, 400.0);
let radii = |d: &ChartData| -> Vec<f32> {
render(&c, d, w, h, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, .. } => Some(*r_outer),
_ => None,
})
.collect()
};
let outer = radii(&many);
assert!(!outer.is_empty(), "a sub-pixel ring must not suppress the whole chart");
let mut distinct = outer.clone();
distinct.sort_by(f32::total_cmp);
distinct.dedup();
assert!(distinct.len() > 8, "only {} rings drawn", distinct.len());
assert!(distinct.len() < 255, "too many rings for the pixels available: {}", distinct.len());
// The outermost ring still reaches the plot radius the single-ring chart uses.
let r = radii(&single).into_iter().fold(0.0f32, f32::max);
let drawn = distinct.last().copied().unwrap_or(0.0);
assert!((drawn - r).abs() < 0.01, "outermost ring {drawn} of {r}");
}
#[test]
fn doughnut_one_pixel_rings_skip_the_separator_stroke() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
// 255 series over a 46-pixel annulus: every drawn ring is one pixel tall, narrower than the
// 1.5-pixel white separator, which would otherwise paint over both neighbouring rings.
let thin = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
};
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thin, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { stroke, .. } => Some(*stroke),
_ => None,
})
.collect();
assert!(!strokes.is_empty(), "the chart must still draw its rings");
assert!(strokes.iter().all(Option::is_none), "a 1.5px stroke covers a 1px band: {strokes:?}");
// A ring with room for the separator keeps it.
let thick = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Inner", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
series("Outer", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thick, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { stroke, .. } => Some(*stroke),
_ => None,
})
.collect();
assert!(!strokes.is_empty());
assert!(strokes.iter().all(Option::is_some), "wide rings keep their separators: {strokes:?}");
}
#[test]
fn doughnut_wedge_budget_bounds_many_series_and_categories() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.hole_size = 75;
c.first_slice_angle = 0;
// 20,000 categories across 80 series would be hundreds of thousands of wedges: the per-series
// limit does not bound the product. The first ten categories of every ring are the largest.
let cats = 20_000usize;
let values: Vec<f64> = (0..cats).map(|j| if j < 10 { 1000.0 } else { 1.0 }).collect();
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: (0..cats).map(|j| format!("C{j}")).collect(),
series: (0..80)
.map(|i| SeriesData { name: format!("S{i}"), values: values.iter().map(|v| Some(*v)).collect(), ..SeriesData::default() })
.collect(),
};
let wedges: Vec<(f32, f32)> = render(&c, &d, 640.0, 400.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { r_outer, a0, a1, .. } => Some((*r_outer, *a1 - *a0)),
_ => None,
})
.collect();
assert!(!wedges.is_empty(), "the innermost ring must still be drawn");
let mut radii: Vec<f32> = wedges.iter().map(|(r, _)| *r).collect();
radii.sort_by(f32::total_cmp);
radii.dedup();
assert!(radii.len() > 1, "every drawable ring is capped, not dropped");
// A slice crossing 12 o'clock is split into two primitives, so each ring may add one.
let budget = crate::render::MAX_WEDGES + radii.len();
assert!(wedges.len() <= budget, "{} wedges for {} rings", wedges.len(), radii.len());
// The first (innermost) ring keeps its largest slices and still fills the circle.
let inner: Vec<f32> = wedges.iter().filter(|(r, _)| *r == radii[0]).map(|(_, a)| *a).collect();
assert!(inner.len() < cats, "the ring was not capped: {}", inner.len());
assert_eq!(inner.iter().filter(|a| **a > 0.1).count(), 10, "largest slices went missing");
let cover: f32 = inner.iter().sum();
assert!((cover - std::f32::consts::TAU).abs() < 0.01, "first ring covers {cover}");
}
#[test]
fn doughnut_first_slice_angle_rotates_the_start() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.legend = LegendPos::None;
c.first_slice_angle = 90;
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let first = prims.iter().find_map(|p| match p {
Prim::Wedge { a0, .. } => Some(*a0),
_ => None,
});
assert!(first.is_some_and(|a| (a - std::f32::consts::FRAC_PI_2).abs() < 0.01), "{first:?}");
}
/// Every slice must occupy exactly its angular share, even when the first slice angle pushes the
/// start past 12 o'clock (the split at TAU used to put whole slices over their neighbours).
#[test]
fn rotated_wedges_cover_each_slice_once_without_overlap() {
const TAU: f32 = std::f32::consts::TAU;
for kind in [ChartKind::Doughnut, ChartKind::Pie] {
for angle in [180u16, 270] {
let mut c = chart(kind);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.first_slice_angle = angle;
let d = ChartData {
category_levels: vec![],
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0; 4], [20, 96, 130, 255], kind)],
};
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let wedges: Vec<(f32, f32)> = prims
.iter()
.filter_map(|p| match p {
Prim::Wedge { a0, a1, .. } => Some((*a0, *a1)),
_ => None,
})
.collect();
assert!(wedges.len() >= 4, "{kind:?} at {angle}: {wedges:?}");
for (a0, a1) in &wedges {
assert!((0.0..TAU).contains(a0), "{kind:?} at {angle}: start {a0} outside [0, TAU)");
assert!(*a1 > *a0 && *a1 <= TAU + 1e-4, "{kind:?} at {angle}: bad sweep {a0}..{a1}");
}
// Sample away from the boundaries: a correct four-slice chart covers every angle once.
let steps = 1440;
for k in 0..steps {
let theta = (k as f32 + 0.5) * TAU / steps as f32;
let cover = wedges.iter().filter(|(a0, a1)| theta >= *a0 && theta < *a1).count();
assert_eq!(cover, 1, "{kind:?} at {angle}: {theta} covered {cover} times by {wedges:?}");
}
}
}
}
/// A slice that crosses 12 o'clock is filled as two halves but outlined once: stroking each half
/// separately ran the white separator along the shared radial edge, straight through the slice.
#[test]
fn slice_crossing_twelve_has_no_internal_seam() {
const TAU: f32 = std::f32::consts::TAU;
let mut c = chart(ChartKind::Pie);
c.title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.first_slice_angle = 90;
let a = [230, 126, 34, 255];
let b = [39, 174, 96, 255];
let d = ChartData {
category_levels: vec![],
palette: vec![a, b],
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
series: vec![series("Only", &[1.0, 1.0], a, ChartKind::Pie)],
};
let (w, h) = (400u32, 400u32);
let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure);
// The second slice starts at 270° and wraps past 0°, so it is drawn as two unstroked halves.
let mut halves = 0;
let mut cx = 0.0;
let mut cy = 0.0;
let mut r = 0.0;
for p in &prims {
if let Prim::Wedge { cx: x, cy: y, r_outer, a0, a1, fill, stroke, .. } = p
&& *fill == b
{
assert!(stroke.is_none(), "a split half must not stroke its shared edge: {p:?}");
assert!(*a0 == 0.0 || *a1 == TAU, "the crossing slice is split at 12 o'clock: {p:?}");
halves += 1;
(cx, cy, r) = (*x, *y, *r_outer);
}
}
assert_eq!(halves, 2, "one slice crosses 12 o'clock");
// Exactly one outline covers the whole crossing slice, drawn as line primitives.
assert_eq!(prims.iter().filter(|p| matches!(p, Prim::Line { color, .. } if *color == [255, 255, 255, 255])).count(), 1, "{prims:?}");
// No white pixel inside the slice along 12 o'clock, where the shared edge used to be stroked.
let img = rasterize(&prims, w, h, [255, 255, 255, 255]);
for t in [0.3f32, 0.5, 0.7] {
assert_eq!(pixel(&img, w, cx as u32, (cy - r * t) as u32), b, "white seam at {t} of the slice");
}
}
#[test]
fn doughnut_legend_names_categories_and_matches_every_ring() {
let mut c = chart(ChartKind::Doughnut);
c.title = None;
c.data_labels = false;
c.legend = LegendPos::Right;
c.first_slice_angle = 180;
let d = ChartData {
category_levels: vec![],
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
series: vec![
series("Outer", &[1.0; 4], [20, 96, 130, 255], ChartKind::Doughnut),
series("Inner", &[1.0; 4], [221, 132, 82, 255], ChartKind::Doughnut),
],
};
let entries = legend_entries(&c, &d);
assert_eq!(entries.iter().map(|(name, _, _)| name.as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
let start = f32::from(c.first_slice_angle) * std::f32::consts::TAU / 360.0;
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
let mut rings = Vec::new();
for p in &prims {
let Prim::Wedge { r_outer, a0, a1, fill, .. } = p else { continue };
rings.push(*r_outer);
let rel = ((a0 + a1) / 2.0 - start).rem_euclid(std::f32::consts::TAU);
let cat = ((rel / (std::f32::consts::TAU / 4.0)) as usize).min(3);
assert_eq!(*fill, entries[cat].1, "ring wedge at {a0}..{a1} does not match legend entry {}", entries[cat].0);
}
rings.sort_by(f32::total_cmp);
rings.dedup();
assert_eq!(rings.len(), 2, "two series must draw two rings");
}
#[test]
fn legend_counts() {
let d = sample(ChartKind::ColumnClustered);
@@ -461,7 +775,11 @@ fn pie_starts_at_twelve_clockwise() {
assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4));
assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3);
assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4])));
let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure);
let mut c = chart(ChartKind::Doughnut);
c.hole_size = 50;
let mut d = sample(ChartKind::Doughnut);
d.series.truncate(1);
let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure);
let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3));
assert!(ok);
}
@@ -1101,6 +1419,38 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}
#[test]
fn drawing_and_resolving_agree_on_the_combo_plot_kind() {
// Resolved series carry `effective_kind`; drawing re-derives the kind from it. Both paths
// must give the same kind for every chart kind, series kind (or none) and series index.
let series_kinds = std::iter::once(None).chain(ALL_KINDS.iter().map(|k| Some(*k)));
for ck in ALL_KINDS {
for sk in series_kinds.clone() {
for i in 0..3 {
let mut c = chart(ck);
c.series = (0..3).map(|j| Series { kind: if j == i { sk } else { None }, ..Default::default() }).collect();
let from_chart = series_plot_kind(&c, i);
let resolved = crate::resolve::effective_kind(ck, sk, i);
assert_eq!(crate::resolve::plot_kind(ck, Some(resolved), i), from_chart, "{ck:?} {sk:?} {i}");
if ck != ChartKind::Combo {
assert_eq!(from_chart, ck);
} else {
assert_ne!(from_chart, ChartKind::Combo);
}
}
}
}
}
#[test]
fn ticks_of_a_scale_off_the_step_grid_start_at_its_min() {
let s = Scale { min: 3.0, max: 7.0, step: 4.0 };
assert_eq!(s.ticks(), vec![3.0, 7.0]);
// Aligned scales still snap float error away.
let s = Scale { min: 0.0, max: 1.0, step: 0.1 };
assert!((s.ticks()[3] - 0.3).abs() < 1e-12 && s.ticks()[0] == 0.0);
}
#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
+279
View File
@@ -0,0 +1,279 @@
//! Work and memory are bounded by the product of the chart limits (series x points, trendlines
//! per chart, hairline bars), and clean data is rendered without a copy.
use std::borrow::Cow;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{ChartKind, Series, Trendline, TrendlineKind, Workbook};
use super::{assert_sane, chart};
use crate::render::{sanitize, sanitized};
use crate::resolve::{MAX_POINTS, MAX_TOTAL_POINTS, MAX_TRENDLINES, points_per_series};
use crate::*;
fn data_of(series: usize, points: usize) -> ChartData {
ChartData {
palette: vec![],
categories: (0..points).map(|i| i.to_string()).collect(),
category_levels: vec![],
series: (0..series)
.map(|s| SeriesData {
name: format!("S{s}"),
values: (0..points).map(|i| Some(((i * 7 + s * 13) % 100 + 1) as f64)).collect(),
color: series_color(&Default::default(), s),
..SeriesData::default()
})
.collect(),
}
}
fn series_rects(prims: &[Prim], color: Rgba) -> Vec<(f32, f32, f32, f32)> {
prims
.iter()
.filter_map(|p| match p {
Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == color => Some((*x, *y, *w, *h)),
_ => None,
})
.collect()
}
#[test]
fn the_point_budget_keeps_every_series_with_fewer_points() {
assert_eq!(points_per_series(1), MAX_POINTS);
assert_eq!(points_per_series(20), MAX_POINTS);
assert_eq!(points_per_series(21), MAX_TOTAL_POINTS / 21);
assert_eq!(points_per_series(255), MAX_TOTAL_POINTS / 255);
assert_eq!(points_per_series(0), MAX_POINTS);
// 25 series x 100,000 points is 2.5 million: over budget.
let d = data_of(25, MAX_POINTS);
let kept = sanitized(&d);
assert_eq!(kept.series.len(), 25, "no series is dropped");
assert!(kept.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 25));
assert_eq!(kept.categories.len(), MAX_TOTAL_POINTS / 25);
assert!(kept.series.iter().map(|s| s.values.len()).sum::<usize>() <= MAX_TOTAL_POINTS);
// The first points are the ones kept.
assert_eq!(kept.series[3].values[..5], d.series[3].values[..5]);
// Drawing it stays bounded: hairline columns are merged per pixel column.
let mut c = chart(ChartKind::ColumnClustered);
c.data_labels = false;
c.legend = LegendPos::None;
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 400.0, 300.0);
assert!(prims.len() < 25 * 450 + 200, "{} primitives", prims.len());
}
#[test]
fn trendlines_are_limited_per_chart() {
let line = |kind| TrendlineData { spec: Trendline { kind, ..Trendline::default() }, color: [1, 2, 3, 255] };
let mut d = data_of(3, 30);
for s in &mut d.series {
s.trendlines = vec![line(TrendlineKind::Linear); 10];
}
let kept = sanitized(&d);
let counts: Vec<usize> = kept.series.iter().map(|s| s.trendlines.len()).collect();
assert_eq!(counts, vec![10, MAX_TRENDLINES - 10, 0], "series order decides which trendlines stay");
let entries = legend_entries(&chart(ChartKind::Line), &kept);
assert_eq!(entries.len(), 3 + MAX_TRENDLINES);
}
#[test]
fn hairline_columns_are_merged_and_wide_ones_are_not() {
let mut c = chart(ChartKind::ColumnClustered);
c.data_labels = false;
c.legend = LegendPos::None;
// Wide bars (>= 0.25 px): one rectangle per series and category, as before.
let d = data_of(3, 40);
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
for s in &d.series {
assert_eq!(series_rects(&prims, s.color).len(), 40);
}
// 4 series x 20,000 categories in 400 px: the bars are hairlines.
let d = data_of(4, 20_000);
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 400.0, 300.0);
for s in &d.series {
let n = series_rects(&prims, s.color).len();
assert!((1..=420).contains(&n), "{n} rectangles for one series");
}
// The merged rectangles keep the envelope: alternating 1 and 10 draws the full-height bar.
let mut d = data_of(1, 20_000);
for (i, v) in d.series[0].values.iter_mut().enumerate() {
*v = Some(if i % 2 == 0 { 1.0 } else { 10.0 });
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let rects = series_rects(&prims, d.series[0].color);
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
assert!(rects.len() > 100 && tallest > 100.0);
assert!(rects.iter().all(|r| (r.3 - tallest).abs() < 0.01), "every pixel column shows the tall bar");
// And together they cover the plot's width without a gap wider than a pixel.
let mut xs: Vec<(f32, f32)> = rects.iter().map(|r| (r.0, r.0 + r.2)).collect();
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(xs.windows(2).all(|w| w[1].0 - w[0].1 < 1.0));
// Horizontal bars too.
let mut c = chart(ChartKind::BarClustered);
c.data_labels = false;
c.legend = LegendPos::None;
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let n = series_rects(&prims, d.series[0].color).len();
assert!((1..=320).contains(&n), "{n} bars");
}
#[test]
fn clustered_hairline_series_keep_a_visible_strip() {
// Four series of similar height, 20,000 categories in 400 px: every pixel column holds a
// merged bar of each series, and they must not cover each other.
for kind in [ChartKind::ColumnClustered, ChartKind::BarClustered] {
let horizontal = kind == ChartKind::BarClustered;
let mut c = chart(kind);
c.data_labels = false;
c.legend = LegendPos::None;
let mut d = data_of(4, 20_000);
for (s, series) in d.series.iter_mut().enumerate() {
for (i, v) in series.values.iter_mut().enumerate() {
*v = Some(50.0 + ((i + s) % 3) as f64);
}
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let along = |r: &(f32, f32, f32, f32)| if horizontal { (r.1, r.1 + r.3) } else { (r.0, r.0 + r.2) };
let mut strips: Vec<(f32, f32, usize)> = Vec::new();
for (s, series) in d.series.iter().enumerate() {
strips.extend(series_rects(&prims, series.color).iter().map(|r| (along(r).0, along(r).1, s)));
}
strips.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(strips.len() > 400);
// Rectangles of different series never overlap (more than rounding).
for w in strips.windows(2) {
assert!(w[0].2 == w[1].2 || w[1].0 >= w[0].1 - 1e-3, "series {} and {} overlap", w[0].2, w[1].2);
}
// And all four series show up in the same pixel column.
let col = strips[strips.len() / 2].0.floor();
let seen: std::collections::BTreeSet<usize> = strips.iter().filter(|s| s.0.floor() == col).map(|s| s.2).collect();
assert_eq!(seen.len(), 4, "series visible in pixel column {col}");
}
}
#[test]
fn stacked_hairline_bars_do_not_fill_the_gap_between_disjoint_spans() {
// Series 1 alternates +10 (stacked on the 100) and -10 (below zero): its two spans in one
// pixel column are 100..110 and -10..0, and the 100 units between them must stay empty.
let mut c = chart(ChartKind::ColumnStacked);
c.data_labels = false;
c.legend = LegendPos::None;
let mut d = data_of(2, 20_000);
for v in d.series[0].values.iter_mut() {
*v = Some(100.0);
}
for (i, v) in d.series[1].values.iter_mut().enumerate() {
*v = Some(if i % 2 == 0 { 10.0 } else { -10.0 });
}
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let rects = series_rects(&prims, d.series[1].color);
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
assert!(!rects.is_empty() && tallest < 60.0, "tallest merged bar is {tallest} px");
}
#[test]
fn clean_data_is_rendered_without_a_copy() {
let d = data_of(3, 50);
assert!(matches!(sanitized(&d), Cow::Borrowed(_)));
// Dirty data takes the copying path and comes out as `sanitize` has always produced it.
let mut dirty = data_of(3, 50);
dirty.series[0].values[4] = Some(f64::NAN);
dirty.series[1].values[5] = Some(f64::INFINITY);
dirty.series[2].values[6] = Some(1e301);
dirty.series[2].x = Some(vec![Some(f64::NEG_INFINITY), Some(2.0)]);
dirty.series[1].sizes = Some(vec![Some(-1e305)]);
let got = sanitized(&dirty);
assert!(matches!(got, Cow::Owned(_)));
assert_eq!(*got, sanitize(&dirty));
assert_eq!(got.series[0].values[4], None);
assert_eq!(got.series[2].values[6], Some(1e300));
// Over the series cap (within the point budget) the same.
let many = data_of(300, 3);
let got = sanitized(&many);
assert_eq!(got.series.len(), 255);
assert_eq!(*got, sanitize(&many));
// Too many category levels take the copying path too.
let mut levels = data_of(3, 50);
levels.category_levels = vec![vec!["a".into()]; 5];
let got = sanitized(&levels);
assert!(matches!(got, Cow::Owned(_)));
assert_eq!(got.category_levels.len(), 3);
}
#[test]
fn resolve_keeps_every_series_within_the_point_budget() {
// 40 series x 60,000 points is 2.4 million: over the 2 million budget.
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
for r in 0..60_000u32 {
s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r)));
}
}
let mut c = chart(ChartKind::ColumnClustered);
c.series =
(0..40).map(|_| Series { values: "Sheet1!$A$1:$A$1000000".into(), categories: Some("A1:A1000000".into()), ..Series::default() }).collect();
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.len(), 40);
assert!(d.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 40));
assert_eq!(d.categories.len(), MAX_TOTAL_POINTS / 40);
assert_eq!(d.series[7].values.first(), Some(&Some(0.0)));
// Trendlines: 16 per chart, in series order.
c.series = (0..4).map(|_| Series { values: "A1:A5".into(), trendlines: vec![Default::default(); 9], ..Series::default() }).collect();
let counts: Vec<usize> = resolve(&wb, 0, &c).series.iter().map(|s| s.trendlines.len()).collect();
assert_eq!(counts, vec![9, 7, 0, 0]);
}
/// 100 series lower the per-series cap to 20,000, below the number of category cells of a
/// two-level axis (#1155). Each kept category keeps its inner label and its outer level.
fn two_level_chart(n: u32, vertical: bool) -> ChartData {
let at = |i: u32, level: u32| if vertical { CellRef::new(i, level) } else { CellRef::new(level, i) };
let mut wb = Workbook::new();
if let Some(s) = wb.sheet_mut(0) {
for i in 0..n {
s.set_value(at(i, 0), Value::Text(format!("Y{}", i / 3).into()));
s.set_value(at(i, 1), Value::Text(format!("C{i}").into()));
s.set_value(at(i, 2), Value::Number(f64::from(i)));
}
}
let cats = format!("{}:{}", at(0, 0), at(n - 1, 1));
let values = format!("{}:{}", at(0, 2), at(n - 1, 2));
let mut c = chart(ChartKind::ColumnClustered);
c.series = (0..100).map(|_| Series { values: values.clone(), categories: Some(cats.clone()), ..Series::default() }).collect();
resolve(&wb, 0, &c)
}
#[test]
fn multi_level_categories_keep_their_levels_under_the_point_cap() {
for vertical in [true, false] {
let d = two_level_chart(15_000, vertical);
assert_eq!(d.series[0].values.len(), 15_000, "vertical {vertical}");
assert_eq!(d.categories.len(), 15_000, "vertical {vertical}");
assert_eq!(d.categories[0], "C0");
assert_eq!(d.categories[14_999], "C14999");
assert_eq!(d.category_levels.len(), 1, "one outer level, vertical {vertical}");
assert_eq!(d.category_levels[0].len(), 15_000);
assert_eq!(d.category_levels[0][3], "Y1");
}
}
#[test]
fn multi_level_categories_longer_than_the_cap_are_cut_together_with_their_levels() {
// 25,000 categories against a cap of 20,000.
let d = two_level_chart(25_000, true);
let cap = points_per_series(100);
assert_eq!(d.categories.len(), cap);
assert_eq!(d.category_levels.len(), 1);
assert_eq!(d.category_levels[0].len(), cap);
assert_eq!(d.categories[cap - 1], format!("C{}", cap - 1));
assert_eq!(d.category_levels[0][cap - 3], format!("Y{}", (cap - 3) / 3));
}
+29
View File
@@ -1,6 +1,35 @@
use super::*;
use gridcraft_model::ChartDataTable;
#[test]
fn combo_table_keys_use_each_series_index() {
let mut c = chart(ChartKind::Combo);
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let mut d = sample(c.kind);
// The public renderer also accepts Combo series: the first defaults to columns, later ones to lines.
for s in &mut d.series {
s.kind = ChartKind::Combo;
}
let p = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
assert_sane(&p, 900.0, 500.0);
let header_y = p
.iter()
.find_map(|p| match p {
Prim::Text { text, y, .. } if text == "Q1" => Some(*y),
_ => None,
})
.unwrap();
for (i, s) in d.series.iter().enumerate() {
let boxes = p.iter().filter(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == s.color && *y > header_y)).count();
let lines =
p.iter().filter(|p| matches!(p, Prim::Line { pts, color, .. } if *color == s.color && pts.iter().all(|p| p[1] > header_y))).count();
assert_eq!(boxes, usize::from(i == 0), "series {i}: column key");
assert_eq!(lines, usize::from(i > 0), "series {i}: line key");
}
}
#[test]
fn tables_draw_source_values_for_every_supported_chart_kind() {
for kind in ALL_KINDS {
+216 -2
View File
@@ -72,7 +72,12 @@ pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFi
let mut aty = vec![0.0f64; m];
for &(x, y) in &data {
let u = (x - center) / scale;
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
// `d` is at most 6, so the powers `first..=d` always fit the array (no allocation per point).
let mut buf = [0.0f64; 7];
for (slot, k) in buf.iter_mut().zip(first..=d) {
*slot = u.powi(k as i32);
}
let pows = buf.get(..m)?;
for (i, pi) in pows.iter().enumerate() {
if let Some(v) = aty.get_mut(i) {
*v += pi * (y - b0);
@@ -258,10 +263,45 @@ fn trim_zeros(s: &str) -> String {
/// Moving average of `period` consecutive points in plotted order: point `i` (from
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
///
/// Linear in the number of points: the points are cut into blocks of `period`, and a window is
/// the tail of one block plus the head of the next, taken from running sums that only ever add
/// (a running sum that also subtracts would lose small values after a huge one passed through).
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
let p = period.clamp(2, 255) as usize;
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
let n = pts.len();
if n < p {
return vec![];
}
// `head[i]`: sum of the block's points up to and including `i`; `tail[i]`: from `i` to the block's end.
let mut head = vec![0.0f64; n];
let mut tail = vec![0.0f64; n];
for (b, block) in pts.chunks(p).enumerate() {
let (mut h, mut t) = (0.0f64, 0.0f64);
for k in 0..block.len() {
h += block.get(k).map_or(0.0, |q| q.1);
if let Some(v) = head.get_mut(b * p + k) {
*v = h;
}
let r = block.len() - 1 - k;
t += block.get(r).map_or(0.0, |q| q.1);
if let Some(v) = tail.get_mut(b * p + r) {
*v = t;
}
}
}
(p - 1..n)
.filter_map(|i| {
let s = i + 1 - p;
let mut sum = tail.get(s)? + if s.is_multiple_of(p) { 0.0 } else { *head.get(i)? };
if !sum.is_finite() {
// A block sum overflowed although the window itself may not: sum it directly.
sum = pts.get(s..=i)?.iter().map(|q| q.1).sum();
}
Some((pts.get(i)?.0, sum / p as f64))
})
.collect()
}
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
@@ -276,3 +316,177 @@ pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
};
format!("{k} ({series})")
}
#[cfg(test)]
mod tests {
use super::*;
/// xorshift64 for repeatable pseudo-random data.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn unit(&mut self) -> f64 {
(self.next() % 1_000_000) as f64 / 1_000_000.0 - 0.5
}
}
/// The window-by-window moving average this module used to compute.
fn moving_average_naive(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
let p = period.clamp(2, 255) as usize;
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
}
#[test]
fn linear_moving_average_matches_the_window_sums() {
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
for case in 0..60 {
let n = (r.next() % 700) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let y = match case % 5 {
0 => r.unit() * 100.0,
1 => 1e9 + r.unit(),
// A huge value passing through must not leave rounding residue behind.
2 => {
if r.next().is_multiple_of(50) {
1e300 * r.unit().signum()
} else {
r.unit()
}
}
3 => {
if i % 2 == 0 {
1e15
} else {
-1e15 + r.unit()
}
}
_ => {
if r.next().is_multiple_of(9) {
f64::NAN
} else {
r.unit() * 1e-3
}
}
};
(i as f64, y)
})
.collect();
for period in [0, 2, 3, 7, 50, 255, 400] {
let fast = moving_average(&pts, period);
let slow = moving_average_naive(&pts, period);
assert_eq!(fast.len(), slow.len(), "case {case} period {period}");
let p = period.clamp(2, 255) as usize;
let finite: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
for (i, ((fx, fy), (sx, sy))) in fast.iter().zip(&slow).enumerate() {
assert_eq!(fx, sx);
let mass: f64 = finite.iter().skip(i).take(p).map(|q| q.1.abs()).sum::<f64>() / p as f64;
assert!((fy - sy).abs() <= 1e-12 * mass, "case {case} period {period} point {i}: {fy} vs {sy}");
}
}
}
}
#[test]
fn moving_average_recomputes_a_window_whose_block_sums_overflow() {
let pts: Vec<(f64, f64)> = [0.0, -1e308, 1e308, 1e308, -1e308].iter().enumerate().map(|(i, y)| (i as f64, *y)).collect();
let fast = moving_average(&pts, 4);
let slow = moving_average_naive(&pts, 4);
assert_eq!(fast, slow);
assert!(fast.iter().all(|q| q.1.is_finite()));
}
/// The trendline fit as it was written with a `Vec` of powers per data point.
fn fit_reference(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
let kind = t.kind;
let d = degree(kind)?;
let data: Vec<(f64, f64)> = pts
.iter()
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
.collect();
if data.len() < 2 || data.len() <= d {
return None;
}
let fixed = match kind {
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
_ => None,
}
.filter(|b| b.is_finite());
let n = data.len() as f64;
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let first = usize::from(fixed.is_some());
let b0 = fixed.unwrap_or(0.0);
let m = d + 1 - first;
let mut ata = vec![vec![0.0f64; m]; m];
let mut aty = vec![0.0f64; m];
for &(x, y) in &data {
let u = (x - center) / scale;
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
for (i, pi) in pows.iter().enumerate() {
aty[i] += pi * (y - b0);
for (j, pj) in pows.iter().enumerate() {
ata[i][j] += pi * pj;
}
}
}
let sol = solve(ata, aty)?;
let mut a = vec![b0; d + 1];
for (k, v) in sol.into_iter().enumerate() {
a[k + first] = v;
}
let coef = expand(&a, center, scale)?;
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
for &(x, y) in &data {
ss_res += (y - fit.poly(x)).powi(2);
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
}
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
Some(TrendFit { r2, ..fit })
}
#[test]
fn fit_without_a_vec_per_point_is_bit_identical() {
let mut r = Rng(0x1234_5678_9ABC_DEF1);
let kinds = [
TrendlineKind::Linear,
TrendlineKind::Exponential,
TrendlineKind::Logarithmic,
TrendlineKind::Power,
TrendlineKind::Polynomial(2),
TrendlineKind::Polynomial(3),
TrendlineKind::Polynomial(6),
TrendlineKind::MovingAverage(3),
];
for case in 0..40 {
let n = 2 + (r.next() % 60) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let x = i as f64 * (1.0 + r.unit()) + 1.0;
let y = if case % 4 == 0 { f64::NAN } else { (r.unit() + 0.6) * 10f64.powi((case % 7) - 2) };
(x, if case % 4 == 0 && i % 3 != 0 { r.unit() + 1.0 } else { y })
})
.collect();
for kind in kinds {
for intercept in [None, Some(0.5), Some(-1.0)] {
let t = Trendline { kind, intercept, ..Trendline::default() };
assert_eq!(fit_trendline(&t, &pts), fit_reference(&t, &pts), "case {case} {kind:?} {intercept:?}");
}
}
}
}
}
+39 -2
View File
@@ -147,10 +147,10 @@ impl RangeRef {
RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))
}
pub fn height(&self) -> u32 {
self.end.row - self.start.row + 1
self.end.row.saturating_sub(self.start.row).saturating_add(1)
}
pub fn width(&self) -> u32 {
self.end.col - self.start.col + 1
self.end.col.saturating_sub(self.start.col).saturating_add(1)
}
pub fn count(&self) -> u64 {
self.height() as u64 * self.width() as u64
@@ -296,10 +296,47 @@ pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
}
}
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
/// quoted state, so a doubled one toggles it twice.
pub fn split_areas(reference: &str) -> Vec<&str> {
let mut areas = Vec::new();
let (mut start, mut quoted) = (0, false);
for (i, c) in reference.char_indices() {
match c {
'\'' => quoted = !quoted,
',' if !quoted => {
areas.push(reference.get(start..i).unwrap_or_default());
start = i.saturating_add(1);
}
_ => {}
}
}
areas.push(reference.get(start..).unwrap_or_default());
areas
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_range_dimensions_saturate_instead_of_wrapping() {
let r = RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) };
assert_eq!((r.height(), r.width()), (u32::MAX, u32::MAX));
assert_eq!(r.count(), u64::from(u32::MAX) * u64::from(u32::MAX));
}
#[test]
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
assert_eq!(split_areas(""), [""]);
}
#[test]
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
assert!(RangeRef::parse("+1:+3").is_none());
+4 -2
View File
@@ -1,5 +1,5 @@
//! GridCraft core types: cell addresses, values and Excel's coercion rules, error values,
//! date serials and typed-input parsing. No dependencies on the rest of the workspace.
//! date serials, regional formats and typed-input parsing. No dependencies on the rest of the workspace.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -7,11 +7,13 @@ pub mod addr;
pub mod color;
pub mod date;
pub mod parse;
pub mod region;
pub mod value;
pub use addr::{
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
split_sheet, unquote_sheet,
split_areas, split_sheet, unquote_sheet,
};
pub use date::DateSystem;
pub use region::Region;
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
+883
View File
@@ -0,0 +1,883 @@
//! Regional formats: how a region writes numbers, dates, times and money.
//!
//! Excel takes these from the operating system's regional settings; they are independent of the
//! interface language and of the formula language. A [`Region`] changes what people *type* into
//! a cell (`1.234,56`, `25/09/2026`) and what cells *show* (separators, month and day names,
//! AM/PM, the system short and long dates). It never changes what is stored: values are numbers,
//! and the number format codes saved in a file stay in their canonical en-US spelling.
//!
//! [`EN_US`] is the default and reproduces GridCraft's original behaviour exactly.
//!
//! # Where the values come from
//! The separators, month and weekday names, AM/PM words, currency symbols and the order of day,
//! month and year are the Unicode CLDR conventions (`unicode-org/cldr-json`:
//! `cldr-numbers-full/main/<locale>/numbers.json` and `cldr-dates-full/main/<locale>/ca-gregorian.json`),
//! which is also what macOS, Android and ICU ship. They were read from those files for en-GB,
//! en-IN, cs and ru, read from ICU's CLDR 48 data for the Swiss regions and nl-NL, and written
//! from the same conventions for the other regions. CLDR data is under the Unicode License v3,
//! reproduced in `NOTICE` (row in `ATTRIBUTION.md`).
//! Deliberate choices where operating systems differ from CLDR:
//! - Short dates use a four-digit year (`dd/mm/yyyy`, where CLDR has `dd/MM/y` or `dd/MM/yy`):
//! Excel's short date is the system's, Windows writes four digits, and a two-digit year would
//! not read back to the same date.
//! - The long date is CLDR's *full* date (with the weekday), like Windows en-US, except where
//! Windows' long date differs: Russian, Japanese and Chinese have no weekday (`23 августа
//! 2005 г.`, `2005年8月23日`), as in Microsoft's National Language Support tables.
//! - Korean writes the short date `yyyy-mm-dd` (CLDR: `yy. M. d.`), also as in Microsoft's
//! National Language Support tables.
//! - Month names for `mmm` are CLDR's abbreviated names (so en-GB has `Sept`), except
//! Portuguese, which drops CLDR's period (`jan`, not `jan.`) as Excel in Brazil shows it.
//! - French and Portuguese (Portugal) group digits with U+00A0 (Windows), not CLDR's U+202F.
//! Parsing accepts every space-like grouping character in those regions.
//! - The Swiss regions (en-CH, de-CH, fr-CH, it-CH; Liechtenstein uses de-CH) group digits with
//! CLDR's ASCII apostrophe (`1'234.50`); parsing also accepts the typographic `’` that glibc
//! uses. French (Switzerland) writes a decimal point like the other Swiss regions and glibc's
//! `fr_CH`, where CLDR has a comma.
//!
//! Regions this module doesn't list (`de-AT`, `es-MX`, `en-CA`…) resolve to the main region of
//! their language through [`lookup`]; their own conventions (a Mexican decimal point) are not
//! modelled.
/// How digits are grouped.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grouping {
/// Groups of three (`1,234,567`).
Thousands,
/// The Indian system: three digits, then groups of two (`12,34,567`).
Indian,
}
/// The order of the parts of a short date.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateOrder {
Mdy,
Dmy,
Ymd,
}
/// One region's conventions. See the module documentation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Region {
/// BCP 47 tag, `pt-BR`.
pub tag: &'static str,
/// The region's name in its own language.
pub name: &'static str,
/// Windows locale id, used by `[$-409]`-style format tags.
pub lcid: u16,
pub decimal: char,
pub group: char,
pub grouping: Grouping,
pub date_order: DateOrder,
pub date_sep: char,
pub time_sep: char,
/// The system short date as a canonical format code (`dd/mm/yyyy`): built-in format 14.
pub short_date: &'static str,
/// The system long date (`[$-F800]`).
pub long_date: &'static str,
/// The system short time. It contains `AM/PM` in regions that use a 12-hour clock.
pub short_time: &'static str,
/// The system long time (`[$-F400]`).
pub long_time: &'static str,
/// Built-in format 22: the short date and a 24-hour time.
pub datetime: &'static str,
pub months: [&'static str; 12],
pub months_abbr: [&'static str; 12],
/// The month names a date with a day uses (Russian `1 января`, Czech `1. ledna`), where they
/// differ from `months`.
pub months_genitive: Option<[&'static str; 12]>,
/// Sunday first.
pub weekdays: [&'static str; 7],
pub weekdays_abbr: [&'static str; 7],
pub am_pm: [&'static str; 2],
pub currency: &'static str,
/// The currency format with the currency's usual decimals (two, or none for the yen and the
/// won), as a canonical code. Where it starts with a quote the symbol comes before the number,
/// otherwise after.
pub currency_format: &'static str,
/// The same with no decimals (for a typed whole amount).
pub currency_format_int: &'static str,
}
const EN_MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const EN_MONTHS_ABBR: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const EN_DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const EN_DAYS_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const AM_PM: [&str; 2] = ["AM", "PM"];
/// English (United States): the default. Identical to the formats before regions existed.
pub static EN_US: Region = Region {
tag: "en-US",
name: "English (United States)",
lcid: 0x0409,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Mdy,
date_sep: '/',
time_sep: ':',
short_date: "m/d/yyyy",
long_date: "dddd, mmmm dd, yyyy",
short_time: "h:mm AM/PM",
long_time: "h:mm:ss AM/PM",
datetime: "m/d/yyyy h:mm",
months: EN_MONTHS,
months_abbr: EN_MONTHS_ABBR,
months_genitive: None,
weekdays: EN_DAYS,
weekdays_abbr: EN_DAYS_ABBR,
am_pm: AM_PM,
currency: "$",
currency_format: "\"$\"#,##0.00",
currency_format_int: "\"$\"#,##0",
};
static EN_GB: Region = Region {
tag: "en-GB",
name: "English (United Kingdom)",
lcid: 0x0809,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd, d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd/mm/yyyy hh:mm",
months: EN_MONTHS,
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
months_genitive: None,
weekdays: EN_DAYS,
weekdays_abbr: EN_DAYS_ABBR,
am_pm: AM_PM,
currency: "£",
currency_format: "\"£\"#,##0.00",
currency_format_int: "\"£\"#,##0",
};
static EN_IN: Region = Region {
tag: "en-IN",
name: "English (India)",
lcid: 0x4009,
decimal: '.',
group: ',',
grouping: Grouping::Indian,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd, d mmmm yyyy",
short_time: "h:mm AM/PM",
long_time: "h:mm:ss AM/PM",
datetime: "dd/mm/yyyy h:mm",
months: EN_MONTHS,
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
months_genitive: None,
weekdays: EN_DAYS,
weekdays_abbr: EN_DAYS_ABBR,
am_pm: AM_PM,
currency: "₹",
currency_format: "\"₹\"#,##0.00",
currency_format_int: "\"₹\"#,##0",
};
static EN_CH: Region = Region {
tag: "en-CH",
name: "English (Switzerland)",
// English (Switzerland) has no LCID of its own: Windows reports LOCALE_CUSTOM_UNSPECIFIED.
lcid: 0x1000,
decimal: '.',
group: '\'',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd, d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: EN_MONTHS,
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
months_genitive: None,
weekdays: EN_DAYS,
weekdays_abbr: EN_DAYS_ABBR,
am_pm: AM_PM,
currency: "CHF",
currency_format: "\"CHF\" #,##0.00",
currency_format_int: "\"CHF\" #,##0",
};
static ES_ES: Region = Region {
tag: "es-ES",
name: "Español (España)",
lcid: 0x0C0A,
decimal: ',',
group: '.',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
short_time: "h:mm",
long_time: "h:mm:ss",
datetime: "dd/mm/yyyy h:mm",
months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
months_abbr: ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"],
months_genitive: None,
weekdays: ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
weekdays_abbr: ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
am_pm: ["a. m.", "p. m."],
currency: "€",
currency_format: "#,##0.00 \"€\"",
currency_format_int: "#,##0 \"€\"",
};
static DE_DE: Region = Region {
tag: "de-DE",
name: "Deutsch (Deutschland)",
lcid: 0x0407,
decimal: ',',
group: '.',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd, d. mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
months_abbr: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
months_genitive: None,
weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
weekdays_abbr: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
am_pm: AM_PM,
currency: "€",
currency_format: "#,##0.00 \"€\"",
currency_format_int: "#,##0 \"€\"",
};
static DE_CH: Region = Region {
tag: "de-CH",
name: "Deutsch (Schweiz)",
lcid: 0x0807,
decimal: '.',
group: '\'',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd, d. mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
months_abbr: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
months_genitive: None,
weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
weekdays_abbr: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
am_pm: AM_PM,
currency: "CHF",
currency_format: "\"CHF\" #,##0.00",
currency_format_int: "\"CHF\" #,##0",
};
static FR_FR: Region = Region {
tag: "fr-FR",
name: "Français (France)",
lcid: 0x040C,
decimal: ',',
group: '\u{a0}',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd/mm/yyyy hh:mm",
months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
months_abbr: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
months_genitive: None,
weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
weekdays_abbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
am_pm: AM_PM,
currency: "€",
currency_format: "#,##0.00 \"€\"",
currency_format_int: "#,##0 \"€\"",
};
static FR_CH: Region = Region {
tag: "fr-CH",
name: "Français (Suisse)",
lcid: 0x100C,
decimal: '.',
group: '\'',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd, d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
months_abbr: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
months_genitive: None,
weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
weekdays_abbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
am_pm: AM_PM,
currency: "CHF",
currency_format: "#,##0.00 \"CHF\"",
currency_format_int: "#,##0 \"CHF\"",
};
static IT_IT: Region = Region {
tag: "it-IT",
name: "Italiano (Italia)",
lcid: 0x0410,
decimal: ',',
group: '.',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd/mm/yyyy hh:mm",
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
months_abbr: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
months_genitive: None,
weekdays: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
weekdays_abbr: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
am_pm: AM_PM,
currency: "€",
currency_format: "#,##0.00 \"€\"",
currency_format_int: "#,##0 \"€\"",
};
static IT_CH: Region = Region {
tag: "it-CH",
name: "Italiano (Svizzera)",
lcid: 0x0810,
decimal: '.',
group: '\'',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd, d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
months_abbr: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
months_genitive: None,
weekdays: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
weekdays_abbr: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
am_pm: AM_PM,
currency: "CHF",
currency_format: "\"CHF\" #,##0.00",
currency_format_int: "\"CHF\" #,##0",
};
static NL_NL: Region = Region {
tag: "nl-NL",
name: "Nederlands (Nederland)",
lcid: 0x0413,
decimal: ',',
group: '.',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '-',
time_sep: ':',
short_date: "dd-mm-yyyy",
long_date: "dddd d mmmm yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd-mm-yyyy hh:mm",
months: ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"],
months_abbr: ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"],
months_genitive: None,
weekdays: ["zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"],
weekdays_abbr: ["zo", "ma", "di", "wo", "do", "vr", "za"],
am_pm: AM_PM,
currency: "€",
currency_format: "\"€\" #,##0.00",
currency_format_int: "\"€\" #,##0",
};
static PT_BR: Region = Region {
tag: "pt-BR",
name: "Português (Brasil)",
lcid: 0x0416,
decimal: ',',
group: '.',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd/mm/yyyy hh:mm",
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
months_genitive: None,
weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"],
weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"],
am_pm: AM_PM,
currency: "R$",
currency_format: "\"R$\" #,##0.00",
currency_format_int: "\"R$\" #,##0",
};
static PT_PT: Region = Region {
tag: "pt-PT",
name: "Português (Portugal)",
lcid: 0x0816,
decimal: ',',
group: '\u{a0}',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '/',
time_sep: ':',
short_date: "dd/mm/yyyy",
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd/mm/yyyy hh:mm",
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
months_genitive: None,
weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"],
weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"],
am_pm: AM_PM,
currency: "€",
currency_format: "#,##0.00 \"€\"",
currency_format_int: "#,##0 \"€\"",
};
static RU_RU: Region = Region {
tag: "ru-RU",
name: "Русский (Россия)",
lcid: 0x0419,
decimal: ',',
group: '\u{a0}',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "d mmmm yyyy\" г.\"",
short_time: "hh:mm",
long_time: "hh:mm:ss",
datetime: "dd.mm.yyyy hh:mm",
months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"],
months_abbr: ["янв.", "февр.", "март", "апр.", "май", "июнь", "июль", "авг.", "сент.", "окт.", "нояб.", "дек."],
months_genitive: Some(["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]),
weekdays: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"],
weekdays_abbr: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
am_pm: AM_PM,
currency: "₽",
currency_format: "#,##0.00\u{a0}\"₽\"",
currency_format_int: "#,##0\u{a0}\"₽\"",
};
static CS_CZ: Region = Region {
tag: "cs-CZ",
name: "Čeština (Česko)",
lcid: 0x0405,
decimal: ',',
group: '\u{a0}',
grouping: Grouping::Thousands,
date_order: DateOrder::Dmy,
date_sep: '.',
time_sep: ':',
short_date: "dd.mm.yyyy",
long_date: "dddd d. mmmm yyyy",
short_time: "h:mm",
long_time: "h:mm:ss",
datetime: "dd.mm.yyyy h:mm",
months: ["leden", "únor", "březen", "duben", "květen", "červen", "červenec", "srpen", "září", "říjen", "listopad", "prosinec"],
months_abbr: ["led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"],
months_genitive: Some(["ledna", "února", "března", "dubna", "května", "června", "července", "srpna", "září", "října", "listopadu", "prosince"]),
weekdays: ["neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"],
weekdays_abbr: ["ne", "po", "út", "st", "čt", "pá", "so"],
am_pm: ["dop.", "odp."],
currency: "Kč",
currency_format: "#,##0.00 \"Kč\"",
currency_format_int: "#,##0 \"Kč\"",
};
static JA_JP: Region = Region {
tag: "ja-JP",
name: "日本語 (日本)",
lcid: 0x0411,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Ymd,
date_sep: '/',
time_sep: ':',
short_date: "yyyy/m/d",
long_date: "yyyy\"年\"m\"月\"d\"日\"",
short_time: "h:mm",
long_time: "h:mm:ss",
datetime: "yyyy/m/d h:mm",
months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
months_genitive: None,
weekdays: ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"],
weekdays_abbr: ["日", "月", "火", "水", "木", "金", "土"],
am_pm: ["午前", "午後"],
currency: "¥",
currency_format: "\"¥\"#,##0",
currency_format_int: "\"¥\"#,##0",
};
static KO_KR: Region = Region {
tag: "ko-KR",
name: "한국어 (대한민국)",
lcid: 0x0412,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Ymd,
date_sep: '-',
time_sep: ':',
short_date: "yyyy-mm-dd",
long_date: "yyyy\"년\" m\"월\" d\"일\" dddd",
short_time: "AM/PM h:mm",
long_time: "AM/PM h:mm:ss",
datetime: "yyyy-mm-dd hh:mm",
months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
months_abbr: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
months_genitive: None,
weekdays: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"],
weekdays_abbr: ["일", "월", "화", "수", "목", "금", "토"],
am_pm: ["오전", "오후"],
currency: "₩",
currency_format: "\"₩\"#,##0",
currency_format_int: "\"₩\"#,##0",
};
static ZH_CN: Region = Region {
tag: "zh-CN",
name: "中文 (中国)",
lcid: 0x0804,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Ymd,
date_sep: '/',
time_sep: ':',
short_date: "yyyy/m/d",
long_date: "yyyy\"年\"m\"月\"d\"日\"",
short_time: "h:mm",
long_time: "h:mm:ss",
datetime: "yyyy/m/d h:mm",
months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
months_genitive: None,
weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"],
weekdays_abbr: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
am_pm: ["上午", "下午"],
currency: "¥",
currency_format: "\"¥\"#,##0.00",
currency_format_int: "\"¥\"#,##0",
};
static ZH_TW: Region = Region {
tag: "zh-TW",
name: "中文 (台灣)",
lcid: 0x0404,
decimal: '.',
group: ',',
grouping: Grouping::Thousands,
date_order: DateOrder::Ymd,
date_sep: '/',
time_sep: ':',
short_date: "yyyy/m/d",
long_date: "yyyy\"年\"m\"月\"d\"日\"",
short_time: "AM/PM hh:mm",
long_time: "AM/PM hh:mm:ss",
datetime: "yyyy/m/d hh:mm",
months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
months_genitive: None,
weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"],
weekdays_abbr: ["週日", "週一", "週二", "週三", "週四", "週五", "週六"],
am_pm: ["上午", "下午"],
currency: "NT$",
currency_format: "\"NT$\"#,##0.00",
currency_format_int: "\"NT$\"#,##0",
};
static ALL: [&Region; 20] = [
&EN_US, &EN_GB, &EN_IN, &EN_CH, &ES_ES, &DE_DE, &DE_CH, &FR_FR, &FR_CH, &IT_IT, &IT_CH, &NL_NL, &PT_BR, &PT_PT, &RU_RU, &CS_CZ, &JA_JP, &KO_KR,
&ZH_CN, &ZH_TW,
];
/// Every region, English (United States) first.
pub fn all() -> &'static [&'static Region] {
&ALL
}
/// The default region, English (United States).
pub fn default_region() -> &'static Region {
&EN_US
}
/// The region with exactly this tag (`pt-BR`; case and `_` don't matter).
pub fn find(tag: &str) -> Option<&'static Region> {
let t = tag.trim().replace('_', "-");
ALL.iter().copied().find(|r| r.tag.eq_ignore_ascii_case(&t))
}
/// The region for a tag as operating systems and browsers write it: `pt-BR`, `pt_BR.UTF-8`,
/// `de`, `en-AU`, `zh-Hant`, `sr_RS@latin`. An exact match wins; otherwise the language's main
/// region (with the usual variants: `en-AU`→`en-GB`, `pt-AO`→`pt-PT`, `zh-Hant`→`zh-TW`).
/// An unknown language is English (United States).
pub fn lookup(tag: &str) -> &'static Region {
if let Some(r) = find(tag) {
return r;
}
let base = tag.split(['.', '@']).next().unwrap_or("");
let norm = base.trim().replace('_', "-");
if let Some(r) = find(&norm) {
return r;
}
let mut parts = norm.split('-').filter(|p| !p.is_empty());
let lang = parts.next().unwrap_or("").to_ascii_lowercase();
let mut script = String::new();
let mut country = String::new();
for p in parts {
// An extension or private-use singleton (`-u-ca-gregory`, `-x-…`) ends the region part.
if p.len() == 1 {
break;
}
if p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) {
script = p.to_ascii_lowercase();
} else if (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())) {
country = p.to_ascii_uppercase();
}
}
let tag = match lang.as_str() {
"en" => match country.as_str() {
"GB" | "AU" | "NZ" | "IE" => "en-GB",
"IN" => "en-IN",
"CH" | "LI" => "en-CH",
_ => "en-US",
},
"pt" => match country.as_str() {
"AO" | "CH" | "CV" | "FR" | "GQ" | "GW" | "LU" | "MO" | "MZ" | "ST" | "TL" | "PT" => "pt-PT",
_ => "pt-BR",
},
"zh" => match country.as_str() {
// An explicit territory wins over the script (`zh-Hant-CN` is China's conventions).
"CN" | "SG" => "zh-CN",
"TW" | "HK" | "MO" => "zh-TW",
_ if script == "hant" => "zh-TW",
_ => "zh-CN",
},
"es" => "es-ES",
"de" => match country.as_str() {
"CH" | "LI" => "de-CH",
_ => "de-DE",
},
"fr" => match country.as_str() {
"CH" | "LI" => "fr-CH",
_ => "fr-FR",
},
"it" => match country.as_str() {
"CH" | "LI" => "it-CH",
_ => "it-IT",
},
"nl" => "nl-NL",
"ru" => "ru-RU",
"cs" => "cs-CZ",
"ja" => "ja-JP",
"ko" => "ko-KR",
_ => "en-US",
};
find(tag).unwrap_or(&EN_US)
}
impl Region {
/// True for the characters that separate groups of digits in this region. A region that
/// groups with a space also accepts the other space-like characters (NBSP, narrow NBSP, thin
/// space), as people type and paste any of them; one that groups with an apostrophe accepts
/// both `'` and `’`.
pub fn is_group(&self, c: char) -> bool {
c == self.group || (is_space_like(self.group) && is_space_like(c)) || (is_apostrophe_like(self.group) && is_apostrophe_like(c))
}
/// True when the currency symbol comes before the number.
pub fn currency_first(&self) -> bool {
self.currency_format.starts_with('"')
}
/// Month number (1–12) for a month name as typed: the full name, the abbreviation (with or
/// without its period) or any prefix of the full name of three or more letters (`Sept`),
/// ignoring case. `word` is one word: the genitive form used after a day (`января` in
/// `1 января`) matches too, and the caller reads the day itself.
pub fn month_from_name(&self, word: &str) -> Option<u32> {
let w = word.trim().to_lowercase();
// Where the abbreviations end in a period (`сент.`, `janv.`), any abbreviation may be typed
// with one (`мар.`, `juin.`). Elsewhere a period is not part of a month name, so en-US
// `Mar. 4, 2026` stays text as before.
let dotted = self.months_abbr.iter().any(|a| a.ends_with('.'));
let stem = if dotted { w.trim_end_matches('.') } else { w.as_str() };
if stem.is_empty() {
return None;
}
let long_enough = stem.chars().count() >= 3;
for (i, full) in self.months.iter().enumerate() {
let full_l = full.to_lowercase();
let abbr_l = self.months_abbr.get(i).map(|a| a.to_lowercase()).unwrap_or_default();
let gen_l = self.months_genitive.as_ref().and_then(|g| g.get(i)).map(|g| g.to_lowercase());
let hit = w == full_l
|| w == abbr_l
|| stem == abbr_l.trim_end_matches('.')
|| gen_l.as_deref() == Some(w.as_str())
|| (long_enough && full_l.starts_with(stem))
|| (long_enough && gen_l.as_deref().is_some_and(|g| g.starts_with(stem)));
if hit {
return Some(i as u32 + 1);
}
}
None
}
/// The name of month `month` (1–12) for `mmmm`; `with_day` selects the genitive form where
/// the region has one.
pub fn month_name(&self, month: u32, with_day: bool) -> &'static str {
let i = (month as usize).wrapping_sub(1);
if with_day && let Some(g) = &self.months_genitive {
return g.get(i).copied().unwrap_or("");
}
self.months.get(i).copied().unwrap_or("")
}
/// True when the short time uses a 12-hour clock.
pub fn is_12_hour(&self) -> bool {
self.short_time.contains("AM/PM")
}
}
fn is_space_like(c: char) -> bool {
matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}')
}
fn is_apostrophe_like(c: char) -> bool {
matches!(c, '\'' | '\u{2019}')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_accepts_system_and_browser_tags() {
let cases = [
("pt-BR", "pt-BR"),
("pt_BR.UTF-8", "pt-BR"),
("pt", "pt-BR"),
("pt-PT", "pt-PT"),
("pt-AO", "pt-PT"),
("de", "de-DE"),
("de-AT", "de-DE"),
("de_CH.UTF-8", "de-CH"),
("de-LI", "de-CH"),
("fr-CH", "fr-CH"),
("fr-LI", "fr-CH"),
("fr-BE", "fr-FR"),
("it_CH.UTF-8", "it-CH"),
("it-LI", "it-CH"),
("en-CH", "en-CH"),
("nl_NL.UTF-8", "nl-NL"),
("nl", "nl-NL"),
("en-AU", "en-GB"),
("en_GB.UTF-8", "en-GB"),
("en-IN", "en-IN"),
("en-CA", "en-US"),
("zh-Hant", "zh-TW"),
("zh-HK", "zh-TW"),
("zh", "zh-CN"),
// Extensions don't replace the region or script; an explicit territory beats the script.
("en-GB-u-ca-gregory", "en-GB"),
("zh-Hant-u-nu-latn", "zh-TW"),
("zh-Hant-CN", "zh-CN"),
("zh-Hans-TW", "zh-TW"),
("cs_CZ.UTF-8", "cs-CZ"),
("C", "en-US"),
("", "en-US"),
("xx-YY", "en-US"),
];
for (tag, expected) in cases {
assert_eq!(lookup(tag).tag, expected, "{tag}");
}
assert_eq!(find("DE-de").map(|r| r.tag), Some("de-DE"));
assert!(find("xx").is_none());
}
#[test]
fn every_region_is_consistent() {
let mut tags = std::collections::HashSet::new();
for r in all() {
assert!(tags.insert(r.tag), "duplicate {}", r.tag);
assert_ne!(r.decimal, r.group, "{}", r.tag);
assert!(r.currency_format.contains(r.currency), "{}", r.tag);
assert!(r.months.iter().all(|m| !m.is_empty()) && r.months_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag);
assert!(r.weekdays.iter().all(|m| !m.is_empty()) && r.weekdays_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag);
assert_eq!(find(r.tag).map(|x| x.tag), Some(r.tag));
}
}
#[test]
fn month_names_match_loosely() {
let de = find("de-DE").unwrap();
assert_eq!(de.month_from_name("März"), Some(3));
assert_eq!(de.month_from_name("mär"), Some(3));
assert_eq!(de.month_from_name("DEZ"), Some(12));
assert_eq!(de.month_from_name("de"), None);
let gb = find("en-GB").unwrap();
assert_eq!(gb.month_from_name("Sept"), Some(9));
assert_eq!(gb.month_from_name("sep"), Some(9));
let ru = find("ru-RU").unwrap();
assert_eq!(ru.month_from_name("сентября"), Some(9));
assert_eq!(ru.month_from_name("сент."), Some(9));
// Abbreviations typed with a period the table doesn't carry.
assert_eq!(ru.month_from_name("мар."), Some(3));
assert_eq!(ru.month_from_name("июн."), Some(6));
assert_eq!(find("fr-FR").unwrap().month_from_name("juin."), Some(6));
assert_eq!(EN_US.month_from_name("Mar."), None);
assert_eq!(de.month_from_name("."), None);
assert_eq!(EN_US.month_from_name("Ma"), None);
assert_eq!(EN_US.month_from_name("May"), Some(5));
}
#[test]
fn space_like_and_apostrophe_groups_are_interchangeable() {
let fr = find("fr-FR").unwrap();
assert!(fr.is_group('\u{a0}') && fr.is_group(' ') && fr.is_group('\u{202f}'));
assert!(!find("de-DE").unwrap().is_group(' '));
let ch = find("de-CH").unwrap();
assert!(ch.is_group('\'') && ch.is_group('\u{2019}'));
assert!(!ch.is_group(' ') && !find("de-DE").unwrap().is_group('\''));
}
}
+258 -10
View File
@@ -346,19 +346,91 @@ pub fn compare_numbers(a: f64, b: f64) -> Ordering {
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
}
/// Case-insensitive text order (Unicode lowercase).
/// Case-insensitive text order in three levels, as Excel's collation sorts: first by letters and
/// digits alone (Arabic marks, tatweel and invisible format characters skipped, Arabic-Indic digits
/// read as digits); then, letter by letter, by the marks on each letter (none first, then in
/// Excel's mark order) and the digit script (ASCII, Arabic-Indic, Extended Arabic-Indic); then, to
/// break a remaining tie, by every character (Unicode lowercase). Only text that is equal at the
/// last level compares equal.
pub fn compare_text(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().flat_map(char::to_lowercase);
let mut bi = b.chars().flat_map(char::to_lowercase);
loop {
match (ai.next(), bi.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) if x != y => return x.cmp(&y),
_ => {}
if !(a.is_ascii() && b.is_ascii()) {
let primary = primary_chars(a).cmp(primary_chars(b));
if primary != Ordering::Equal {
return primary;
}
let secondary = secondary_weights(a).cmp(secondary_weights(b));
if secondary != Ordering::Equal {
return secondary;
}
}
lowercase_chars(a).cmp(lowercase_chars(b))
}
fn lowercase_chars(s: &str) -> impl Iterator<Item = char> + '_ {
s.chars().flat_map(char::to_lowercase)
}
/// Characters Excel's text order skips entirely: tatweel and invisible format characters (soft
/// hyphen, combining grapheme joiner, zero-width (non-)joiner, bidi marks, embeddings, overrides
/// and isolates, word joiner, byte order mark). The zero-width space is not one.
fn ignorable(c: char) -> bool {
matches!(
c,
'\u{00AD}'
| '\u{034F}'
| '\u{061C}'
| '\u{0640}'
| '\u{200C}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'
| '\u{2066}'..='\u{2069}'
| '\u{FEFF}'
)
}
/// Arabic combining marks in the order Excel sorts a letter carrying them; an unmarked letter
/// sorts before all of them. Measured in Excel 16.0 (build 20527, en-US) with one mark on a letter.
/// U+06D6 and U+06D7 sort as letters in Excel, so they are not listed.
const ARABIC_MARK_ORDER: [char; 48] = [
'\u{06EC}', '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{06DB}', '\u{0656}', '\u{0657}', '\u{0610}', '\u{06EB}', '\u{0615}', '\u{06D9}',
'\u{06E4}', '\u{06E1}', '\u{06DA}', '\u{06DC}', '\u{06EA}', '\u{06E3}', '\u{06D8}', '\u{0655}', '\u{065F}', '\u{0654}', '\u{06E7}', '\u{0653}',
'\u{06E8}', '\u{06ED}', '\u{06E2}', '\u{0658}', '\u{064B}', '\u{064C}', '\u{064D}', '\u{064E}', '\u{064F}', '\u{0650}', '\u{0652}', '\u{0651}',
'\u{0670}', '\u{0659}', '\u{065A}', '\u{065B}', '\u{065C}', '\u{065D}', '\u{065E}', '\u{0616}', '\u{0617}', '\u{0618}', '\u{0619}', '\u{061A}',
];
/// Rank of an Arabic mark that letter order skips (its index in `ARABIC_MARK_ORDER`).
fn arabic_mark_rank(c: char) -> Option<u16> {
ARABIC_MARK_ORDER.iter().position(|&m| m == c).and_then(|i| u16::try_from(i).ok())
}
/// Digit script of `c` (0 ASCII, 1 Arabic-Indic, 2 Extended Arabic-Indic) and its ASCII digit.
fn digit(c: char) -> Option<(u16, char)> {
let (script, zero) = match c {
'0'..='9' => return Some((0, c)),
'\u{0660}'..='\u{0669}' => (1, 0x0660),
'\u{06F0}'..='\u{06F9}' => (2, 0x06F0),
_ => return None,
};
char::from_u32(u32::from(c) - zero + u32::from('0')).map(|d| (script, d))
}
/// The characters that decide text order before any tie-break.
fn primary_chars(s: &str) -> impl Iterator<Item = char> + '_ {
lowercase_chars(s).filter_map(|c| if ignorable(c) || arabic_mark_rank(c).is_some() { None } else { Some(digit(c).map_or(c, |(_, d)| d)) })
}
/// The second level, for text whose primary characters are equal: per letter, its digit script and
/// then its marks, the letters' lists closed by 0 so that an unmarked letter sorts first. Marks
/// before the first letter form a list of their own.
fn secondary_weights(s: &str) -> impl Iterator<Item = u16> + '_ {
lowercase_chars(s)
.filter(|&c| !ignorable(c))
.flat_map(|c| match arabic_mark_rank(c) {
Some(rank) => [Some(rank.saturating_add(1)), None],
None => [Some(0), Some(digit(c).map_or(0, |(script, _)| script))],
})
.flatten()
.chain(std::iter::once(0))
}
/// Sorting order used by Sort (ascending): numbers, text, booleans, errors, then blanks last.
@@ -457,6 +529,182 @@ mod tests {
assert_eq!(sort_compare(&Value::Empty, &Value::Number(1.0)), Ordering::Greater);
}
#[test]
fn arabic_diacritics_sort_with_their_base_letter() {
assert_eq!(compare_text("بَاب", "بيت"), Ordering::Less);
assert_eq!(compare_text("بيت", "بَاب"), Ordering::Greater);
assert_eq!(compare_text("كِتاب", "كتب"), Ordering::Less);
assert_eq!(compare_text("رحمٰن", "رحمي"), Ordering::Less);
assert_eq!(compare(&Value::from("بَاب"), &Value::from("بيت")), Ordering::Less);
}
#[test]
fn arabic_indic_digits_sort_with_digits() {
assert_eq!(compare_text("١٢", "ب"), Ordering::Less);
assert_eq!(compare_text("۱۲", "ب"), Ordering::Less);
assert_eq!(compare_text("١", "a"), Ordering::Less);
assert_eq!(compare_text("١٢", "13"), Ordering::Less);
assert_eq!(compare_text("۱۴", "13"), Ordering::Greater);
}
#[test]
fn tatweel_and_bidi_marks_do_not_move_sort_order() {
assert_eq!(compare_text("بـاب", "بت"), Ordering::Less);
assert_eq!(compare_text("\u{200F}apple", "banana"), Ordering::Less);
assert_eq!(compare_text("\u{200E}zebra", "apple"), Ordering::Greater);
assert_eq!(compare_text("\u{061C}a", "b"), Ordering::Less);
assert_eq!(compare_text("a\u{200C}b", "ac"), Ordering::Less);
assert_eq!(compare_text("a\u{200D}b", "ac"), Ordering::Less);
}
#[test]
fn primary_ties_keep_equality_unchanged() {
assert_eq!(compare_text("ABC", "abc"), Ordering::Equal);
assert_eq!(compare_text("باب", "بَاب"), Ordering::Less);
assert_eq!(compare_text("12", "١٢"), Ordering::Less);
assert_eq!(compare_text("a", "\u{200F}a"), Ordering::Less);
let samples = [
"",
"abc",
"ABC",
"abd",
"ab",
"باب",
"بَاب",
"بِاب",
"بـاب",
"بيت",
"١٢",
"۱۲",
"12",
"\u{200F}12",
"\u{200F}a",
"a",
"A\u{200C}",
"a\u{200F}",
"b",
"Straße",
"STRASSE",
"ΣΑΣ",
"σας",
"İ",
"i̇",
];
let lowered = |s: &str| s.chars().flat_map(char::to_lowercase).collect::<String>();
for a in samples {
for b in samples {
let order = compare_text(a, b);
assert_eq!(order.is_eq(), lowered(a) == lowered(b), "{a:?} vs {b:?}");
assert_eq!(order, compare_text(b, a).reverse(), "{a:?} vs {b:?}");
for c in samples {
if order.is_le() && compare_text(b, c).is_le() {
assert!(compare_text(a, c).is_le(), "{a:?} <= {b:?} <= {c:?}");
}
}
}
}
}
/// Sorts `expected` from reversed and rotated starts and checks each comes back as `expected`.
#[track_caller]
fn assert_sorts_to(expected: &[&str]) {
let mut reversed: Vec<&str> = expected.iter().rev().copied().collect();
reversed.sort_by(|a, b| compare_text(a, b));
assert_eq!(reversed, expected);
let mut rotated: Vec<&str> = expected.to_vec();
rotated.rotate_left(expected.len() / 2);
rotated.sort_by(|a, b| compare_text(a, b));
assert_eq!(rotated, expected);
}
// The expected orders below are Excel's, observed with Range.Sort, =SORT() and the `<`
// operator in Excel 16.0 (build 20527, en-US). Strings Excel ranks equal are left out.
#[test]
fn excel_orders_a_letter_by_its_mark() {
assert_sorts_to(&[
"باب",
"ب\u{0611}اب",
"ب\u{0610}اب",
"ب\u{0654}اب",
"ب\u{064B}اب",
"ب\u{064C}اب",
"ب\u{064D}اب",
"ب\u{064E}اب",
"ب\u{064F}اب",
"ب\u{0650}اب",
"ب\u{0652}اب",
"ب\u{0651}اب",
"ب\u{0670}اب",
"ب\u{065E}اب",
"ب\u{061A}اب",
]);
// Quranic marks are skipped by letter order like harakat (hamza sorts before alef).
assert_eq!(compare_text("ب\u{0610}ء", "باب"), Ordering::Less);
assert_eq!(compare_text("ب\u{06E1}ء", "باب"), Ordering::Less);
}
#[test]
fn excel_compares_marks_letter_by_letter() {
assert_sorts_to(&[
"بت",
"بت\u{064E}",
"بت\u{064F}",
"بت\u{0650}",
"ب\u{064E}ت",
"ب\u{064E}ت\u{064E}",
"ب\u{064E}ت\u{064F}",
"ب\u{064F}ت",
"ب\u{064F}ت\u{064E}",
"ب\u{0650}ت\u{0650}",
]);
assert_sorts_to(&["مـحـمـد", "محم\u{0651}د", "م\u{064E}حمد", "م\u{064F}ح\u{064E}م\u{064E}\u{0651}د", "محمدي"]);
}
#[test]
fn excel_weighs_marks_and_digit_scripts_by_position() {
// A mark on an earlier letter outranks the digit script or letter after it, whatever the
// code point of that next character.
assert_sorts_to(&[
"ب",
"ب\u{064E}",
"ب1",
"ب١",
"ب۱",
"ب\u{064E}1",
"ب\u{064E}١",
"ب\u{064E}۱",
"بa",
"ب\u{064E}a",
"با",
"ب\u{064E}ا",
"ب\u{067E}",
"ب\u{064E}\u{067E}",
"ب\u{06CC}",
"ب\u{064E}\u{06CC}",
]);
// And an earlier digit script outranks a later mark.
assert_sorts_to(&["12", "1٢", "١2", "١٢", "1ب", "1ب\u{064E}", "1ب\u{0650}", "١ب", "١ب\u{064E}", "١ب\u{0650}", "۱ب", "۱ب\u{064E}"]);
assert_sorts_to(&["ب1ب", "ب1ب\u{064E}", "ب١ب", "ب\u{064E}ب", "بب1", "بب١", "ب\u{064E}ب1"]);
}
#[test]
fn excel_skips_invisible_format_characters() {
for c in ['\u{00AD}', '\u{034F}', '\u{061C}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}']
.into_iter()
.chain(['\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{FEFF}'])
{
let s = format!("{c}b");
assert_eq!(compare_text(&s, "a"), Ordering::Greater, "{s:?}");
assert_eq!(compare_text(&s, "ba"), Ordering::Less, "{s:?}");
assert_eq!(compare_text(&format!("ب{c}\u{064E}اب"), "باب\u{064B}"), Ordering::Greater, "{c:?}");
}
// Not even as a tie-break: a leading mark doesn't push text past marked or other-script forms.
assert_sorts_to(&["\u{200F}باب", "ب\u{064B}اب", "ب\u{064E}اب", "ب\u{0670}اب"]);
assert_sorts_to(&["\u{200F}12", "١٢", "۱۲"]);
assert_sorts_to(&["ب\u{200F}ب\u{064E}", "\u{200F}ب\u{064E}ب"]);
}
#[test]
fn errors() {
for e in CellError::ALL {
+1
View File
@@ -21,6 +21,7 @@ web-time = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
gridcraft-xlsx = { workspace = true }
craft-csv = { workspace = true }
gridcraft-chart = { workspace = true }
gridcraft-pdf = { workspace = true }
gridcraft-math = { workspace = true }
+19 -2
View File
@@ -118,7 +118,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("insert.picture", "Insert", "Illustrations", "Pictures"),
("insert.shape", "Insert", "Illustrations", "Shapes"),
("insert.icons", "Insert", "Illustrations", "Icons"),
("insert.smartArt", "Insert", "Illustrations", "SmartArt"),
("insert.smartArt", "Insert", "Illustrations", "Diagram"),
("insert.screenshot", "Insert", "Illustrations", "Screenshot"),
("insert.recommendedCharts", "Insert", "Charts", "Recommended Charts"),
("insert.chart", "Insert", "Charts", "Charts"),
@@ -131,7 +131,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("review.newComment", "Insert", "Comments", "Comment"),
("insert.textBox", "Insert", "Text", "Text Box"),
("pageLayout.headerFooter", "Insert", "Text", "Header & Footer"),
("insert.wordArt", "Insert", "Text", "WordArt"),
("insert.wordArt", "Insert", "Text", "Decorative Text"),
("insert.signatureLine", "Insert", "Text", "Signature Line"),
("insert.object", "Insert", "Text", "Object"),
("insert.equation", "Insert", "Symbols", "Equation"),
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Developer (Insert › Controls in GridCraft)
("insert.button", "Developer", "Controls", "Button (Form Control)"),
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
("control.format", "Developer", "Controls", "Properties"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
@@ -335,3 +340,15 @@ pub fn parity() -> (usize, usize) {
let done = ids.iter().filter(|id| crate::find_command(id).is_some()).count();
(done, ids.len())
}
#[cfg(test)]
mod feature_name_tests {
#[test]
fn own_feature_labels_keep_command_ids() {
for (id, expected) in [("insert.smartArt", "Diagram"), ("insert.wordArt", "Decorative Text")] {
let entries: Vec<_> = super::CATALOG.iter().filter(|entry| entry.0 == id).collect();
assert_eq!(entries.len(), 1, "{id} appears once");
assert_eq!(entries[0].3, expected);
}
}
}
+158 -4
View File
@@ -1,7 +1,7 @@
//! Conditional formatting evaluation: what extra style, data bar, colour scale or icon a cell
//! gets.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
@@ -47,6 +47,8 @@ enum StatsNeed {
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
match rule {
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
// only two- and three-colour scales paint: no statistics for the others
CfRule::ColorScale { stops } if !matches!(stops.len(), 2 | 3) => None,
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
Some(StatsNeed::Numbers)
}
@@ -70,15 +72,58 @@ thread_local! {
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Most cells one rule's statistics visit, across all of its ranges together: a rule repeating
/// the same range many times must not multiply the scan.
const STATS_SCAN_LIMIT: usize = 2_000_000;
/// Steps through the spilled cells inside `r`, row-major: `Some(cell)` for each, and `None` for a
/// jump past the spills left or right of the columns on a row (one step per row, however many
/// there are), so unrelated spills between a narrow range's corners cost little.
fn spill_steps(sh: &Sheet, r: RangeRef) -> impl Iterator<Item = Option<CellRef>> + '_ {
let mut cur = Some(r.start);
std::iter::from_fn(move || {
let at = cur.filter(|at| *at <= r.end)?;
let (&c, _) = sh.spill.range(at..=r.end).next()?;
if c.col < r.start.col {
cur = Some(CellRef::new(c.row, r.start.col));
Some(None)
} else if c.col > r.end.col {
cur = c.row.checked_add(1).map(|row| CellRef::new(row, r.start.col));
Some(None)
} else {
cur = c.col.checked_add(1).map(|col| CellRef::new(c.row, col));
Some(Some(c))
}
})
}
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
let mut nums = Vec::new();
let mut counts = HashMap::new();
let mut budget = STATS_SCAN_LIMIT;
let mut seen: HashSet<RangeRef> = HashSet::new();
let used = sh.used_range();
for r in ranges {
let r = match sh.used_range().and_then(|u| r.intersection(&u)) {
if budget == 0 {
break;
}
let r = match used.and_then(|u| r.intersection(&u)) {
Some(x) => x,
None => continue,
};
for c in r.iter().take(2_000_000) {
// The same range listed twice counts its cells once.
if !seen.insert(r) {
continue;
}
// Only stored and spilled cells hold values: blank cells of a sparse range cost nothing,
// so a value far down a huge range is still counted. Every cell looked at is charged.
let stored = sh.cells.iter_range(r).map(|(c, _)| (Some(c), true));
let spilled = spill_steps(sh, r).map(|c| (c, false));
for (c, is_stored) in stored.chain(spilled).take(budget) {
budget -= 1;
// a skip over spills outside the columns (charged, not read), or a spilled value
// under a stored cell, which was read with it
let Some(c) = c.filter(|c| is_stored || sh.cells.get(*c).is_none()) else { continue };
#[cfg(test)]
SCANNED.with(|n| n.set(n.get() + 1));
let v = sh.value(c);
@@ -278,7 +323,8 @@ impl CfCache {
_ => None,
},
CfRule::ColorScale { stops } => {
if let Some(n) = v.as_f64() {
// Only two- and three-colour scales paint: evaluate no thresholds for others.
if let Some(n) = v.as_f64().filter(|_| matches!(stops.len(), 2 | 3)) {
let pts: Vec<(f64, [u8; 3])> = stops
.iter()
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
@@ -368,6 +414,9 @@ impl CfCache {
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
/// are unset).
fn layer_under(style: &mut Style, lower: &Style) {
if style.align.reading_order.is_none() {
style.align.reading_order = lower.align.reading_order;
}
if style.fill.pattern == PatternType::None {
style.fill = lower.fill;
}
@@ -417,6 +466,28 @@ mod tests {
use super::*;
#[test]
fn reading_order_respects_conditional_rule_priority_and_explicit_context() {
for (higher, expected) in [
(json!({"font": {"bold": true}}), "RightToLeft"),
(json!({"align": {"reading_order": "Context"}}), "Context"),
(json!({"align": {"reading_order": "LeftToRight"}}), "LeftToRight"),
] {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap();
for style in [json!({"align": {"reading_order": "RightToLeft"}}), higher] {
s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": style}})).unwrap();
}
let wb = &s.doc().unwrap().wb;
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
for wb in [wb, &reopened] {
let style = CfCache::new().look(wb, 0, CellRef::new(0, 0)).unwrap().style.unwrap();
assert_eq!(serde_json::to_value(style.align).unwrap()["reading_order"], expected);
}
}
}
#[test]
fn every_matching_rule_applies() {
let mut s = crate::Session::new();
@@ -560,6 +631,89 @@ mod tests {
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
}
/// A rule listing the same range many times scans it once, and never more than the shared
/// limit however many ranges it holds.
#[test]
fn repeated_ranges_share_one_scan_budget() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
s.execute("range.setValues", json!({"range": "XFD1048576", "values": [[4]]})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
let dense = RangeRef::parse("A1:XFD1048576").unwrap();
let small = RangeRef::parse("A1:A3").unwrap();
SCANNED.with(|n| n.set(0));
let st = RuleStats::build(sh, &vec![small; 1000], StatsNeed::Numbers);
assert_eq!(st.sorted, vec![1.0, 2.0, 3.0]);
assert_eq!(SCANNED.with(|n| n.get()), 3);
let shifted: Vec<RangeRef> = (0..50).map(|i| RangeRef::new(CellRef::new(i, 0), dense.end)).collect();
SCANNED.with(|n| n.set(0));
RuleStats::build(sh, &shifted, StatsNeed::Numbers);
assert!(SCANNED.with(|n| n.get()) <= STATS_SCAN_LIMIT);
}
/// Blank cells of a sparse range are not charged to the scan budget: a value at the end of two
/// million-row columns still counts, so duplicates are found.
#[test]
fn sparse_ranges_keep_their_values_under_the_scan_budget() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[7]]})).unwrap();
s.execute("range.setValues", json!({"range": "B1048576", "values": [[7]]})).unwrap();
let d = s.doc().unwrap();
let sh = d.wb.sheet(0).unwrap();
let ranges = [RangeRef::parse("A1:A1048576").unwrap(), RangeRef::parse("B1:B1048576").unwrap()];
SCANNED.with(|n| n.set(0));
let st = RuleStats::build(sh, &ranges, StatsNeed::Counts);
assert_eq!(st.counts.get("7"), Some(&2));
assert_eq!(SCANNED.with(|n| n.get()), 2);
}
/// Spills left and right of a narrow range, between its corners, are skipped a row at a time:
/// they do not use the budget that the in-range spills need.
#[test]
fn spills_outside_a_narrow_range_do_not_use_its_scan_budget() {
let mut s = crate::Session::new();
s.new_workbook();
let d = s.doc().unwrap();
let mut sh = d.wb.sheet(0).unwrap().clone();
for row in 0..1000 {
for col in [0u32, 5, 6, 7] {
sh.spill.insert(CellRef::new(row, col), Value::Number(1.0));
}
}
let r = RangeRef::parse("F1:G1000").unwrap();
let steps: Vec<_> = spill_steps(&sh, r).collect();
assert_eq!(steps.iter().flatten().count(), 2000);
// column A is skipped one step a row, never one per cell
assert!(steps.len() <= 2000 + 1000, "{}", steps.len());
let narrow = RangeRef::parse("C1:C1000").unwrap();
assert_eq!(spill_steps(&sh, narrow).flatten().count(), 0);
}
/// A colour scale with an unsupported number of stops paints nothing and evaluates none of them.
#[test]
fn colour_scales_with_other_stop_counts_paint_nothing() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [5]]})).unwrap();
let mut wb: Workbook = (*s.doc().unwrap().wb).clone();
let stop = (CfValueKind::Formula("=1".into()), Color::default());
let rule = |n: usize| CondFormat {
ranges: vec![RangeRef::parse("A1:A2").unwrap()],
rule: CfRule::ColorScale { stops: vec![stop.clone(); n] },
priority: 1,
stop_if_true: false,
};
wb.sheet_mut(0).unwrap().cond_formats.push(rule(4));
let mut cache = CfCache::new();
assert_eq!(cache.look(&wb, 0, CellRef::parse("A1").unwrap()).and_then(|l| l.scale_fill), None);
wb.sheet_mut(0).unwrap().cond_formats = vec![rule(2)];
let mut cache = CfCache::new();
assert!(cache.look(&wb, 0, CellRef::parse("A1").unwrap()).and_then(|l| l.scale_fill).is_some());
}
#[test]
fn cell_value_operator_names() {
let mut s = crate::Session::new();
+36 -5
View File
@@ -10,6 +10,10 @@ use crate::io::esc;
/// Widest or tallest chart written; larger anchors are clamped.
const MAX_SIDE: f32 = 16_384.0;
/// Most bytes of SVG written for the charts of one web page together. A chart past what is
/// left makes the export fail rather than grow without bound or be dropped without a word.
pub(crate) const MAX_PAGE_SVG_BYTES: usize = 64 * 1024 * 1024;
struct HelveticaMeasure;
impl gridcraft_chart::Measure for HelveticaMeasure {
@@ -18,24 +22,40 @@ impl gridcraft_chart::Measure for HelveticaMeasure {
}
}
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, `None` for a degenerate size,
/// or an error for an element longer than `budget` bytes.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str, budget: usize) -> crate::Result<Option<String>> {
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
if !(w >= 1.0 && h >= 1.0) {
return None;
return Ok(None);
}
let data = gridcraft_chart::resolve(wb, sheet, chart);
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
Some(prims_svg(&prims, w, h, style))
match prims_svg_within(&prims, w, h, style, budget) {
Some(svg) => Ok(Some(svg)),
None => Err(crate::EngineError::Other(format!(
"the charts would make the web page over {} MiB. Save as .xlsx or PDF instead.",
MAX_PAGE_SVG_BYTES / (1024 * 1024)
))),
}
}
#[cfg(test)]
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
prims_svg_within(prims, w, h, style, usize::MAX).unwrap_or_default()
}
/// The `<svg>` element, or `None` as soon as it grows past `budget` bytes.
fn prims_svg_within(prims: &[Prim], w: f32, h: f32, style: &str, budget: usize) -> Option<String> {
let mut out = String::new();
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
);
for p in prims {
if out.len() > budget {
return None;
}
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
@@ -92,7 +112,7 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
}
}
out.push_str("</svg>");
out
(out.len() <= budget).then_some(out)
}
/// Fill and stroke attributes of an open shape element; the caller closes it.
@@ -149,6 +169,17 @@ fn num(v: f32) -> String {
mod tests {
use super::*;
#[test]
fn an_svg_past_its_byte_budget_is_left_out() {
let rect = Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some([255, 0, 0, 255]), stroke: None, radius: 0.0 };
let prims = vec![rect; 10_000];
let full = prims_svg(&prims, 100.0, 50.0, "");
assert!(full.len() > 100_000);
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len()), Some(full.clone()));
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len() - 1), None);
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", 1_000), None);
}
#[test]
fn every_primitive_becomes_an_svg_element() {
let red = [255, 0, 0, 255];
+273 -7
View File
@@ -2,6 +2,7 @@
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod controls;
pub mod data;
pub mod draw;
pub mod edit;
@@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(controls::specs());
v.extend(equation::specs());
v.extend(data::specs());
v.extend(formulas::specs());
@@ -364,6 +366,10 @@ pub struct Ctx<'a> {
pub wb: Workbook,
pub changed: Vec<Key>,
pub structural: bool,
/// Recalculate every formula without the other effects of `structural` (table headers are not
/// resynchronized, array formulas are still checked): for edits that change only what a
/// function reads, like sheet protection.
pub recalc_all: bool,
pub sel: &'a mut Selection,
/// Rows whose automatic height should be recomputed (sheet, row).
pub fit_rows: Vec<(usize, u32)>,
@@ -398,13 +404,14 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
wb: (*d.wb).clone(),
changed: Vec::new(),
structural: false,
recalc_all: false,
sel: &mut sel,
fit_rows: Vec::new(),
protection_checked: false,
edits: Vec::new(),
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
let Ctx { mut wb, mut changed, structural, recalc_all, mut fit_rows, protection_checked, edits, .. } = ctx;
// Equations whose source changed (or that arrived without a layout, as from a paste) are
// laid out here, so painting only ever reads the cache.
wb.refresh_equations();
@@ -414,6 +421,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
if !structural {
check_arrays(&d.wb, &wb)?;
}
if wb.calc.precision_as_displayed {
round_edited_as_displayed(&d.wb, &mut wb, &mut changed, structural);
}
if let Some(log) = d.touched.as_mut() {
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
if let Some(sh) = wb.sheet(*si) {
@@ -430,18 +440,29 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
}
if structural {
if structural || recalc_all {
d.calc.recalc_all(&mut wb);
} else if !changed.is_empty() {
d.calc.cells_changed(&mut wb, &changed);
}
controls::sync_linked_values(&mut wb);
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", since(t0));
}
fit_rows.sort_unstable();
fit_rows.dedup();
let mut sweep: Option<(usize, MergeSweep)> = None;
let mut left = MERGE_FIT_WORK;
for (si, row) in fit_rows {
auto_row_height(&mut wb, si, row);
if sweep.as_ref().is_none_or(|(at, _)| *at != si) {
if let Some((_, old)) = &sweep {
left = old.left();
}
sweep = Some((si, MergeSweep::new(left)));
}
if let Some((_, sw)) = sweep.as_mut() {
fit_row_height(&mut wb, si, row, sw);
}
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", since(t0));
@@ -451,6 +472,117 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
Ok(r)
}
/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
/// the rest of the workbook; formula cells whose format changed are recalculated, and the
/// recalculation rounds them. A structural edit recalculates everything anyway, so it only
/// rounds its constants (a sheet the edit created, for one, holds written numbers).
fn round_edited_as_displayed(old: &Workbook, wb: &mut Workbook, changed: &mut Vec<gridcraft_calc::Key>, structural: bool) {
let mut prec = gridcraft_calc::DisplayedPrecision::new();
for si in 0..wb.sheets.len() {
let Some(after) = wb.sheets.get(si) else { continue };
// Sheets are matched by name. A sheet the edit created has no earlier version, so all of
// its cells are new.
let before = old.sheet_index(&after.name).and_then(|i| old.sheets.get(i));
if before.is_some_and(|b| std::sync::Arc::ptr_eq(b, after)) {
continue;
}
let diffs: Vec<CellRef> = match before {
Some(before) => before.cells.diff(&after.cells),
None => after.cells.iter().map(|(c, _)| c).collect(),
};
let mut anchors = std::collections::HashSet::new();
for &c in &diffs {
if !structural && after.cell(c).is_some_and(|cell| cell.formula.is_some()) {
changed.push((si, c));
anchors.insert(c);
}
}
let updates = displayed_updates(&mut prec, wb, si, diffs.iter().copied());
// A spill destination is no stored cell: reformatting it (or its row or column) leaves
// no change in the store, so its anchor is recalculated to round the new element.
if let (Some(before), false) = (before, structural) {
for (&anchor, range) in &after.spill_ranges {
if !anchors.contains(&anchor)
&& (diffs.iter().any(|d| range.contains(*d))
|| line_styles_differ(&before.rows, &after.rows, range.start.row, range.end.row)
|| line_styles_differ(&before.cols, &after.cols, range.start.col, range.end.col))
{
changed.push((si, anchor));
}
}
}
if updates.is_empty() {
continue;
}
let Some(sh) = wb.sheet_mut(si) else { continue };
for (c, r) in updates {
if let Some(cell) = sh.cells.get_mut(c) {
cell.value = gridcraft_core::Value::Number(r);
if !structural {
changed.push((si, c));
}
}
}
}
}
/// Whether any row or column style in `lo..=hi` differs between two sheets' line maps.
fn line_styles_differ(
before: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
after: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
lo: u32,
hi: u32,
) -> bool {
let style = |m: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>, k: u32| m.get(&k).and_then(|l| l.style);
after.range(lo..=hi).any(|(k, l)| l.style != style(before, *k)) || before.range(lo..=hi).any(|(k, l)| l.style != style(after, *k))
}
/// Turning "Set precision as displayed" on rounds every number constant in the workbook to its
/// format, permanently (Excel warns "Data will permanently lose accuracy"). Formulas are rounded
/// by the recalculation that follows.
pub(crate) fn round_all_constants_as_displayed(wb: &mut Workbook) {
let mut prec = gridcraft_calc::DisplayedPrecision::new();
for si in 0..wb.sheets.len() {
let Some(sh) = wb.sheets.get(si) else { continue };
let updates = displayed_updates(&mut prec, wb, si, sh.cells.iter().map(|(c, _)| c));
if updates.is_empty() {
continue;
}
let Some(sh) = wb.sheet_mut(si) else { continue };
for (c, r) in updates {
if let Some(cell) = sh.cells.get_mut(c) {
cell.value = gridcraft_core::Value::Number(r);
}
}
}
}
/// The constants among `cells` on sheet `si` whose value the option changes, with their rounded
/// values. A locked constant on a protected sheet keeps its value: protection keeps its content,
/// and rounding it by its format would change the content that the sheet protects.
fn displayed_updates(
prec: &mut gridcraft_calc::DisplayedPrecision,
wb: &Workbook,
si: usize,
cells: impl Iterator<Item = CellRef>,
) -> Vec<(CellRef, f64)> {
let Some(sh) = wb.sheet(si) else { return Vec::new() };
cells
.filter(|&c| !(sh.is_protected() && wb.styles.get(sh.style_id(c)).protection.locked))
.filter_map(|c| {
let cell = sh.cell(c)?;
match cell.value {
gridcraft_core::Value::Number(n) if cell.formula.is_none() => {
let r = prec.round(wb, si, c, n);
(r.to_bits() != n.to_bits()).then_some((c, r))
}
_ => None,
}
})
.collect()
}
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
/// that kept a cell's value, so a shift is refused then.
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
@@ -503,10 +635,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick:
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
// A range limited to Windows users or groups (a security descriptor) can't be checked
// here, so without a password it stays locked rather than opening to every editor.
if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
if r.password.is_some() {
needs_password.get_or_insert(r.title.as_str());
}
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
@@ -600,6 +736,94 @@ fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
/// a multi-line value shows on one line and AutoFit gives a one-line row.
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
let mut sweep = MergeSweep::new(MERGE_FIT_WORK);
fit_row_height(wb, si, row, &mut sweep);
}
/// Most merge comparisons one auto-fit pass (a commit, or opening a workbook) makes. Past it the
/// remaining rows keep the heights they have rather than stall on a sheet whose merges overlap
/// the fitted rows many times over.
pub(crate) const MERGE_FIT_WORK: usize = 50_000_000;
/// The merged areas of one sheet that touch a row, for rows visited in ascending order: the
/// merges are sorted by first row once and swept, so fitting many rows never rescans every merge
/// per row. All comparisons count against [`MERGE_FIT_WORK`].
pub(crate) struct MergeSweep {
/// Built on the first row that needs merge checks, so rows that return early never pay for it.
by_start: Option<Vec<gridcraft_core::RangeRef>>,
next: usize,
active: Vec<gridcraft_core::RangeRef>,
budget: usize,
/// The first row was answered by a plain scan: a pass that fits one row never pays for a sort.
scanned: bool,
}
impl MergeSweep {
/// A sweep for one sheet's merges with `budget` comparisons left for the pass (carry
/// [`MergeSweep::left`] of the previous sheet's sweep into the next). Every call to
/// [`MergeSweep::at`] must pass that same sheet.
pub(crate) fn new(budget: usize) -> Self {
Self { by_start: None, next: 0, active: Vec::new(), budget, scanned: false }
}
/// Comparisons left for the rest of the pass.
pub(crate) fn left(&self) -> usize {
self.budget
}
/// Takes `n` comparisons from the budget; `false` once it is spent.
fn charge(&mut self, n: usize) -> bool {
match self.budget.checked_sub(n) {
Some(rest) => {
self.budget = rest;
true
}
None => {
self.budget = 0;
false
}
}
}
/// The merges covering `row`, or `None` when the budget is spent. Rows must not decrease
/// between calls (skipping rows is fine).
fn at(&mut self, sh: &gridcraft_model::Sheet, row: u32) -> Option<Vec<gridcraft_core::RangeRef>> {
if self.by_start.is_none() && !self.scanned {
self.scanned = true;
if !self.charge(sh.merges.len()) {
return None;
}
return Some(sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect());
}
if self.by_start.is_none() {
// Sorting is about n log2 n comparisons, charged before it starts.
let n = sh.merges.len();
if !self.charge(n.saturating_mul((usize::BITS - n.leading_zeros()) as usize)) {
return None;
}
let mut v: Vec<gridcraft_core::RangeRef> = sh.merges.iter().copied().filter(|m| !m.is_single()).collect();
v.sort_unstable_by_key(|m| m.start.row);
self.by_start = Some(v);
}
let by_start = self.by_start.as_ref()?;
while let Some(m) = by_start.get(self.next).filter(|m| m.start.row <= row) {
self.active.push(*m);
self.next += 1;
}
if !self.charge(self.active.len()) {
return None;
}
self.active.retain(|m| m.end.row >= row);
Some(self.active.clone())
}
}
/// [`auto_row_height`] with the sheet's merges taken from `sweep`.
pub(crate) fn fit_row_height(wb: &mut Workbook, si: usize, row: u32, sweep: &mut MergeSweep) {
// Out of budget: the row keeps the height it has.
if sweep.left() == 0 {
return;
}
let Some(sh) = wb.sheet(si) else { return };
if sh.rows.get(&row).is_some_and(|i| i.custom) {
return;
@@ -629,9 +853,14 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
// a sheet with many merges and many saved columns would otherwise scan all of them per
// cell, rows × merges × columns).
let row_merges: Vec<gridcraft_core::RangeRef> =
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
// Rows are fitted in ascending order, so the merges touching this one come from a sweep over
// the sheet's merges sorted once, not from a scan of all of them. Out of budget: the row keeps
// the height it has.
let Some(row_merges) = sweep.at(sh, row) else { return };
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
if !sweep.charge(row_merges.len()) {
return;
}
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
continue;
@@ -675,3 +904,40 @@ impl Ctx<'_> {
self.changed.push((sheet, c));
}
}
#[cfg(test)]
mod merge_sweep_tests {
use super::*;
#[test]
fn the_budget_carries_from_one_sheets_sweep_to_the_next() {
let mut sheet = gridcraft_model::Sheet::new("S");
for r in ["A1:B2", "C1:D2", "E1:F2"] {
sheet.merges.push(gridcraft_core::RangeRef::parse(r).unwrap());
}
let mut first = MergeSweep::new(5);
assert!(first.at(&sheet, 0).is_some(), "the first row is a plain scan: three comparisons");
assert!(first.at(&sheet, 1).is_none(), "the second row needs the sort: six comparisons, two are left");
assert_eq!(first.left(), 0);
// The next sheet starts with what is left: nothing, so it fits nothing.
let mut second = MergeSweep::new(first.left());
assert!(second.at(&sheet, 0).is_none());
assert!(MergeSweep::new(MERGE_FIT_WORK).at(&sheet, 0).is_some());
}
#[test]
fn sorting_the_merges_is_charged() {
let mut sheet = gridcraft_model::Sheet::new("S");
for r in ["A1:B2", "C1:D2", "E1:F2", "G1:H2"] {
sheet.merges.push(gridcraft_core::RangeRef::parse(r).unwrap());
}
// The first row costs a plain scan (4); from the second on the merges are sorted, about
// 4 * 3 comparisons: 7 left is not enough, 12 plus the row's 4 is.
let mut sweep = MergeSweep::new(11);
assert!(sweep.at(&sheet, 0).is_some());
assert!(sweep.at(&sheet, 1).is_none());
assert_eq!(sweep.left(), 0);
let mut sweep = MergeSweep::new(20);
assert!(sweep.at(&sheet, 0).is_some() && sweep.at(&sheet, 1).is_some());
}
}
+67 -20
View File
@@ -152,26 +152,32 @@ fn input_range(s: &Session, p: &Json, keys: &[&str]) -> Result<(usize, RangeRef)
/// Splits a range into variables (columns, or rows when `by_rows`); with `labels` the first
/// cell of each names it, else `default(i)` does.
fn variables(s: &Session, si: usize, r: RangeRef, by_rows: bool, labels: bool, default: &dyn Fn(usize) -> String) -> Result<Vec<Var>> {
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let (outer, inner) = if by_rows { (r.height(), r.width()) } else { (r.width(), r.height()) };
let outer = if by_rows { r.height() } else { r.width() };
let mut out = Vec::with_capacity(outer as usize);
for i in 0..outer {
let cell = |j: u32| if by_rows { CellRef::new(r.start.row + i, r.start.col + j) } else { CellRef::new(r.start.row + j, r.start.col + i) };
let mut vals: Vec<Value> = (0..inner).map(|j| sh.value(cell(j))).collect();
let mut name = default(i as usize);
if labels && !vals.is_empty() {
let first = vals.remove(0);
let t = first.display();
if !t.trim().is_empty() {
name = t;
}
}
out.push(Var { name, vals });
out.push(variable(s, si, r, by_rows, labels, default, i)?);
}
Ok(out)
}
/// Variable `i` of [`variables`], read alone so a caller that may stop early needn't build them all.
fn variable(s: &Session, si: usize, r: RangeRef, by_rows: bool, labels: bool, default: &dyn Fn(usize) -> String, i: u32) -> Result<Var> {
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let inner = if by_rows { r.width() } else { r.height() };
let cell = |j: u32| if by_rows { CellRef::new(r.start.row + i, r.start.col + j) } else { CellRef::new(r.start.row + j, r.start.col + i) };
let mut vals: Vec<Value> = (0..inner).map(|j| sh.value(cell(j))).collect();
let mut name = default(i as usize);
if labels && !vals.is_empty() {
let first = vals.remove(0);
let t = first.display();
if !t.trim().is_empty() {
name = t;
}
}
Ok(Var { name, vals })
}
/// The numbers of a variable; blanks are skipped when `skip_blank`, anything else that isn't a
/// number is an error.
fn numbers(v: &[Value], skip_blank: bool) -> Result<Vec<f64>> {
@@ -272,11 +278,25 @@ struct Grid {
cells: BTreeMap<(u32, u32), Value>,
/// Cells shown as percentages.
pct: Vec<(u32, u32)>,
/// Rows and columns spanned by `cells`.
rows: u32,
cols: u32,
/// A cell was dropped because the report outgrew [`MAX_OUTPUT_CELLS`]: the report is refused.
too_large: bool,
}
impl Grid {
/// Stores a value. Past [`MAX_OUTPUT_CELLS`] (the most a report writes) nothing more is kept,
/// so a builder that would produce millions of entries stops allocating them, and
/// [`write_report`] refuses the report.
fn put(&mut self, r: u32, c: u32, v: Value) {
if !v.is_empty() {
let (rows, cols) = (self.rows.max(r.saturating_add(1)), self.cols.max(c.saturating_add(1)));
if self.too_large || u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
self.too_large = true;
return;
}
(self.rows, self.cols) = (rows, cols);
self.cells.insert((r, c), v);
}
}
@@ -296,9 +316,7 @@ impl Grid {
}
/// (rows, cols) spanned.
fn extent(&self) -> (u32, u32) {
let rows = self.cells.keys().map(|(r, _)| r + 1).max().unwrap_or(0);
let cols = self.cells.keys().map(|(_, c)| c + 1).max().unwrap_or(0);
(rows, cols)
(self.rows, self.cols)
}
}
@@ -310,7 +328,7 @@ fn idx(i: usize) -> u32 {
fn descriptive(s: &Session, p: &Json) -> Result<Grid> {
let (si, r) = input_range(s, p, &["range", "input"])?;
let vars = variables(s, si, r, by_rows(p), flag(p, "labels"), &|i| format!("Column{}", i + 1))?;
let (by_rows, labels) = (by_rows(p), flag(p, "labels"));
let summary = p.get("summary").map(|_| flag(p, "summary")).unwrap_or(true);
let conf = confidence(p, "confidence")?;
let kl = num(p, "kthLargest").filter(|k| *k >= 1.0).map(|k| k as usize);
@@ -319,7 +337,14 @@ fn descriptive(s: &Session, p: &Json) -> Result<Grid> {
return Err(bad(CMD, "choose at least one of summary, confidence, kthLargest or kthSmallest"));
}
let mut g = Grid::default();
for (i, v) in vars.iter().enumerate() {
// Variables are read one at a time: a million-row range grouped by rows is a million variables.
for i in 0..(if by_rows { r.height() } else { r.width() }) {
// The report is refused once the grid is too large: the rest would only build discarded labels.
if g.too_large {
break;
}
let v = variable(s, si, r, by_rows, labels, &|i| format!("Column{}", i + 1), i)?;
let i = i as usize;
let x = numbers(&v.vals, true)?;
let c = idx(i).saturating_mul(2);
g.txt(0, c, &v.name);
@@ -1749,7 +1774,7 @@ fn sampling(s: &Session, p: &Json, seed: Option<u64>) -> Result<Grid> {
fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json> {
let (rows, cols) = g.extent();
let (rows, cols) = (rows.max(1), cols.max(1));
if u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
if g.too_large || u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
return Err(bad(CMD, "the report is too large"));
}
let d = s.doc()?;
@@ -1914,6 +1939,28 @@ mod tests {
x
}
#[test]
fn a_report_stops_growing_past_the_output_limit() {
// Entries past the limit are not kept, and the report is refused.
let mut g = super::Grid::default();
g.num(1999, 1999, 1.0);
assert!(!g.too_large);
g.num(0, 2000, 1.0);
assert!(g.too_large);
assert_eq!(g.cells.len(), 1);
assert_eq!(g.extent(), (2000, 2000));
// Row-grouped statistics of a tall, almost empty column: one variable per row would make
// millions of report entries; the report is refused instead of built.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1]]})).unwrap();
s.execute("range.setValues", json!({"range": "A1000000", "values": [[2]]})).unwrap();
let err = s
.execute("data.analysisToolPak", json!({"tool": "descriptive", "range": "A1:A1000000", "groupedBy": "rows", "output": "C1"}))
.expect_err("report too large");
assert!(err.to_string().contains("too large"), "{err}");
assert_eq!(v(&s, "C1"), Value::Empty);
}
#[test]
fn forecast_standard_errors_match_the_full_window_rescan() {
// #1043: the running sum must give what re-adding every window gave, gaps included.
+711
View File
@@ -0,0 +1,711 @@
//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
//!
//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
//! its action through the macro policy with the document's trust, so a button from a file someone
//! else made asks before it runs anything.
use gridcraft_core::CellRef;
use gridcraft_model::{
Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook,
};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
has_doc, split_sheet, stash, str_param, u32_param,
};
use crate::macros::policy::{Policy, macro_policy};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.button",
"Button",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
has_doc,
insert_button
),
cmd!(
"insert.slider",
"Slider",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
has_doc,
|s, p| insert_number_control(s, p, ControlKind::Slider)
),
cmd!(
"insert.spinButton",
"Spin Button",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
has_doc,
|s, p| insert_number_control(s, p, ControlKind::Spin)
),
cmd!(
"control.format",
"Format Control",
[],
None,
"{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
has_doc,
format
),
cmd!(
"control.assign",
"Assign Action",
[],
None,
"{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
has_doc,
assign
),
cmd!(
"control.setValue",
"Change Control Value",
[],
None,
"{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
has_doc,
set_value
),
cmd!(
"control.press",
"Press Button",
[],
None,
"{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
has_doc,
press
),
cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list),
]
}
/// The longest caption or name a control takes.
const MAX_TEXT: usize = 1_024;
fn control_json(c: &FormControl) -> Json {
let mut o = json!({
"id": c.id,
"kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
"name": c.display_name(),
"at": c.anchor.cell.a1(),
"width": c.anchor.width,
"height": c.anchor.height,
"print": c.print,
"locked": c.locked,
});
if c.kind == ControlKind::Button {
o["text"] = json!(c.text);
if let Some(a) = &c.action {
o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
}
} else {
o["value"] = json!(c.value);
o["min"] = json!(c.min);
o["max"] = json!(c.max);
o["step"] = json!(c.step);
if c.kind == ControlKind::Slider {
o["page"] = json!(c.page);
o["horizontal"] = json!(c.horizontal);
}
}
if let Some(l) = &c.link {
o["link"] = json!(l);
}
o
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
}
/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
/// else is refused with a message that names the parameter.
fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
let v = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
Some(_) => None,
};
match v {
Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
_ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
}
}
fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
}
}
/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
let text = link.trim().trim_start_matches('=');
let (sheet, body) = split_sheet(text);
let si = match sheet {
Some(n) => wb.sheet_index(&n)?,
None => host,
};
wb.sheet(si)?;
Some((si, CellRef::parse(body.trim())?))
}
/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
/// An empty text removes the link.
fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
let text = match p.get("link") {
None => return Ok(None),
Some(Json::Null) => return Ok(Some(None)),
Some(Json::String(t)) => t.trim(),
Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
};
if text.is_empty() {
return Ok(Some(None));
}
let (si, c) = link_target(wb, host, text)
.ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
Ok(Some(Some(if si == host {
abs
} else {
let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
format!("{}!{abs}", gridcraft_formula::quote_sheet(&name))
})))
}
/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
let cell = match cell_param(s, p, "at")? {
Some(c) => c,
None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
};
let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
Ok(Anchor {
cell,
dx: off("dx"),
dy: off("dy"),
width: dim("width", size.0),
height: dim("height", size.1),
mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
})
}
fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.button";
let anchor = new_anchor(s, p, (96.0, 28.0))?;
let action = action_param(s, CMD, p)?;
let d = s.doc()?;
let id = d.wb.next_object_id();
let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
let sheet = d.wb.active_sheet();
let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(control);
Ok(json!({"control": id}))
})
}
fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
let horizontal = bool_param(p, "horizontal").unwrap_or(true);
let size = match kind {
ControlKind::Slider if horizontal => (144.0, 18.0),
ControlKind::Slider => (18.0, 144.0),
_ => (18.0, 36.0),
};
let anchor = new_anchor(s, p, size)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let id = d.wb.next_object_id();
let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
numbers(cmd, p, &mut c)?;
if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
c.link = link;
}
let given = whole(cmd, p, "value")?;
// Without a value, a control starts at the number its linked cell holds (or its minimum).
let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
c.value = c.snap(start);
let value = c.value;
let link = c.link.clone();
s.grouped(&format!("Insert {}", kind.label()), |s| {
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(c);
Ok(())
})?;
if given.is_some() {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(json!({"control": id}))
})
}
/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(cmd, p, key)? {
*slot = v;
}
}
// A new slider's page follows a larger step unless it was given.
if p.get("page").is_none() && c.page < c.step {
c.page = c.step;
}
c.check_numbers().map_err(|e| bad(cmd, e))
}
/// The number the control's linked cell holds, if it holds one.
fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
wb.sheet(si)?.cell(cell)?.value.as_f64()
}
/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
let Some(link) = link else { return Ok(()) };
let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
if holds == Some(value as f64) {
return Ok(());
}
s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
Ok(())
}
/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
Ok((sheet, i, c))
}
/// Drawing objects on a protected sheet can't be changed while they are locked.
/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
if kind != ObjKind::Control {
return Ok(());
}
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn format(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.format";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if let Some(t) = text_param(CMD, p, "text")? {
c.text = t;
}
if let Some(n) = text_param(CMD, p, "name")? {
c.name = n.trim().to_string();
}
for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
if let Some(v) = bool_param(p, key) {
*slot = v;
}
}
let mut given = None;
if c.kind != ControlKind::Button {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(CMD, p, key)? {
*slot = v;
}
}
c.check_numbers().map_err(|e| bad(CMD, e))?;
given = whole(CMD, p, "value")?;
}
if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
c.link = link;
}
// New bounds pull the value in; a given value is snapped to them.
c.value = c.snap(given.map_or(c.value as f64, f64::from));
let number = c.kind != ControlKind::Button;
let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
let (value, link) = (c.value, c.link.clone());
s.grouped("Format Control", |s| {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(())
})?;
if write {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(())
})?;
// The reply is the control as committed: a new link to a cell holding another number has
// moved its value there.
let (_, _, c) = find(s, CMD, p)?;
Ok(control_json(&c))
}
/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
/// what its command would open, so a web page is refused when the action is assigned or comes in
/// from a file. A command that turns out to open a page or a dialog when it runs is refused
/// then, by [`press`].
const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
/// The macro policy for a button's command, with the button's own refusals first.
fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy {
match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
Some((_, why)) => Policy::Deny((*why).into()),
None => macro_policy(id, params, trust),
}
}
/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
/// person, and says why the press is refused when one would open a dialog or a web page, which
/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
use crate::UiRequest;
let mut blocked = None;
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
for q in asked {
match q {
UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
m @ UiRequest::Message(_) => s.ui_requests.push(m),
}
}
blocked
}
/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
/// an assignment is refused when it is made and dropped to the selection when a file brings one
/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
}
/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
if name.chars().count() > MAX_MACRO_NAME {
return Err(bad(cmd, "`macro` is not a macro name"));
}
let input = match str_param(p, "input").map(str::trim) {
None | Some("") => MacroInput::Selection,
Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
Some(t) => {
let (_, body) = split_sheet(t);
if gridcraft_core::RangeRef::parse(body).is_none() {
return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
}
if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
}
MacroInput::Fixed(t.to_string())
}
};
let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
if let Some(o) = &output
&& gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none()
{
return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
}
return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
}
if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
if find_command(id).is_none() {
return Err(bad(cmd, format!("there is no command `{id}`")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
if !params.is_object() {
return Err(bad(cmd, "`params` must be an object"));
}
let trust = s.doc()?.trust;
if let Policy::Deny(why) = button_policy(id, &params, trust) {
return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
}
return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
}
Ok(None)
}
fn assign(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.assign";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
}
let action = action_param(s, CMD, p)?;
if action.is_none() && bool_param(p, "clear") != Some(true) {
return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
}
c.action = action;
let out = control_json(&c);
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(out)
})
}
fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.setValue";
let (sheet, i, c) = find(s, CMD, p)?;
if c.kind == ControlKind::Button {
return Err(bad(CMD, "a button has no value; use control.press"));
}
let v = match p.get("value") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|v| v.is_finite())
.ok_or_else(|| bad(CMD, "`value` must be a number"))?;
let value = c.snap(v);
let label = format!("Change {}", c.kind.label());
s.grouped(&label, |s| {
if c.value != value {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
slot.value = value;
}
Ok(())
})?;
}
write_link(s, sheet, c.link.as_deref(), value)?;
Ok(json!({"value": value}))
})
}
fn press(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.press";
let (_, _, c) = find(s, CMD, p)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
}
let name = c.display_name();
match c.action {
None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
Some(ControlAction::Macro { name: mac, input, output }) => {
let mut params = json!({"name": mac});
if let MacroInput::Fixed(r) = input {
params["inputs"] = json!(r);
}
if let Some(o) = output {
params["output"] = json!(o);
}
for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
if let Some(v) = p.get(key) {
params[key] = v.clone();
}
}
// The macro runner gates every step with the document's trust.
crate::macros::run::run(s, &mac, &params)
}
Some(ControlAction::Command { id, params }) => {
let d = s.doc()?;
let (doc, trust) = (d.uid, d.trust);
let policy = button_policy(&id, &params, trust);
let file_command = matches!(policy, Policy::NeedsPermission(_));
let why = match policy {
Policy::Allow => None,
Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
};
if let Some(why) = why {
let key = (doc, approval_key(&id, &params));
if !s.macro_approvals.contains(&key) {
if bool_param(p, "approve") != Some(true) {
return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(&params))));
}
s.macro_approvals.insert(key);
}
}
// As a macro step: only an approved file command reaches the file system, so a
// command that reads or writes a file under another parameter name can't.
let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter);
s.grouped(&name, |s| {
let queued = s.ui_requests.len();
let r = s.execute(&id, params.clone());
// As a macro step: a command that would open a dialog or a web page is refused
// and its group rolled back; messages for the person stay queued.
let blocked = take_requests(s, queued, &name);
let r = r?;
match blocked {
Some(why) => Err(EngineError::Other(why)),
None => Ok(r),
}
})
}
}
}
/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
/// is shortened in the middle.
fn path_note(params: &Json) -> String {
let text = match params.get("path") {
None | Some(Json::Null) => return String::new(),
Some(Json::String(t)) => t.clone(),
Some(other) => other.to_string(),
};
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let n = text.chars().count();
let shown = if n > 200 {
let head: String = text.chars().take(100).collect();
let tail: String = text.chars().skip(n - 99).collect();
format!("{head}…{tail}")
} else {
text
};
format!(" ({shown})")
}
/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
fn approval_key(id: &str, params: &Json) -> String {
format!("control-command\u{0}{id}\u{0}{params}")
}
/// After an edit: each slider and spin button whose linked cell holds a number takes that number
/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
/// are left untouched.
pub(crate) fn sync_linked_values(wb: &mut Workbook) {
for si in 0..wb.sheets.len() {
let updates: Vec<(usize, u32)> = match wb.sheet(si) {
Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
.controls
.iter()
.enumerate()
.filter(|(_, c)| c.kind != ControlKind::Button)
.filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
.collect(),
_ => continue,
};
if updates.is_empty() {
continue;
}
if let Some(sh) = wb.sheet_mut(si) {
for (i, v) in updates {
if let Some(c) = sh.controls.get_mut(i) {
c.value = v;
}
}
}
}
}
// ---------------------------------------------------------------- xlsx
/// The hidden-data key of the button actions xlsx has no place for.
pub(crate) const ACTIONS_KEY: &str = "control_actions";
/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
fn plain(a: &ControlAction) -> bool {
matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
}
/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
/// the button's name and anchor cell to check it is still the same button when the file comes
/// back. `None` when there is nothing to store.
pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
let mut items = Vec::new();
for sh in &wb.sheets {
for (i, c) in sh.controls.iter().enumerate() {
if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
}
}
}
let stale = stash::get(wb, ACTIONS_KEY).is_some();
if items.is_empty() && !stale {
return Ok(None);
}
let mut out = wb.clone();
stash::remove(&mut out, ACTIONS_KEY);
if !items.is_empty() {
stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
}
Ok(Some(out))
}
/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
/// button's name and anchor cell, the button at the stored place must have that name and anchor
/// cell (another application may have added, removed or reordered controls), and it must not
/// have been given another macro since (a stored macro action comes back only on a button that
/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
pub(crate) fn load_action_names(wb: &mut Workbook) {
let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
stash::remove(wb, ACTIONS_KEY);
for item in items.as_array().into_iter().flatten().take(100_000) {
let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
let valid = match &action {
ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
};
let Some(si) = wb.sheet_index(sheet) else { continue };
let Ok(index) = usize::try_from(index) else { continue };
let text = |k: &str| item.get(k).and_then(Json::as_str);
let same = |c: &FormControl| {
c.kind == ControlKind::Button
&& text("name").is_some_and(|n| n == c.display_name())
&& text("at").is_some_and(|a| a == c.anchor.cell.a1())
&& match (&c.action, &action) {
(None, ControlAction::Command { .. }) => true,
(Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
_ => false,
}
};
if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
continue;
}
let action = match action {
// The macro takes no inputs now: the stored range would fail every press, so the
// button runs the macro on the selection, as the file itself assigns it.
ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
ControlAction::Macro { name, input: MacroInput::Selection, output }
}
other => other,
};
if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
c.action = Some(action);
}
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
validation
),
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → {invalid: [cell], truncated: bool (true when the 100,000-cell scan allowance ended before every range was checked)}", has_doc, circle_invalid),
CommandSpec {
shortcut_mac: Some("Cmd+Shift+K"),
..cmd!(
+9 -2
View File
@@ -184,7 +184,8 @@ pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return };
let Some(af) = sh.autofilter.clone() else { return };
let r = af.range;
// An imported range can name rows past the grid; none of those exist to hide.
let r = RangeRef::new(af.range.start, CellRef::new(af.range.end.row.min(gridcraft_core::MAX_ROWS - 1), af.range.end.col));
let mut hide = Vec::new();
// 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();
@@ -270,6 +271,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
if let Some(a) = sh.autofilter.as_mut() {
@@ -283,6 +285,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet();
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
edit(s, |cx| {
apply_filter(&mut cx.wb, sheet);
cx.structural = true;
@@ -315,5 +318,9 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
seen.entry((key, t)).or_insert(on);
}
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
let mut values: Vec<_> = seen.into_iter().collect();
values.sort_by(|((a, ta), _), ((b, tb), _)| {
a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then_with(|| gridcraft_core::compare_text(&a.2, &b.2)).then_with(|| ta.cmp(tb))
});
values.into_iter().map(|((_, t), on)| (t, on)).collect()
}
+17
View File
@@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
}
}
/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis.
fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> {
if rows {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)
} else {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)
}
}
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
let si = target_sheet(s, p)?;
// The selection belongs to the active sheet, so another sheet needs its lines.
@@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
}
let (rows, a, b) = outline_lines(s, p)?;
refuse_unless_outline_allowed(s, si, rows)?;
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
let before = outline::summary_before(sh, rows);
@@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
let (rows, a, b) = outline_lines(s, p)?;
refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?;
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
edit(s, |cx| {
let si = cx.sheet_index();
@@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
});
}
let active = s.doc()?.wb.active_sheet();
for (n, is_rows) in [(rows, true), (cols, false)] {
if n.is_some() {
refuse_unless_outline_allowed(s, active, is_rows)?;
}
}
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
+11
View File
@@ -53,6 +53,17 @@ fn subtotal_puts_summary_rows_below_their_detail() {
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
}
#[test]
fn filter_list_orders_arabic_like_sort() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["بج"], ["بُت"], ["\u{200F}بب"]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
let doc = s.doc().unwrap();
let list: Vec<String> = filter_values(&doc.wb, 0, 0).into_iter().map(|(t, _)| t).collect();
assert_eq!(list, ["\u{200F}بب", "بُت", "بج"]);
}
#[test]
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
let mut s = Session::new();
+7
View File
@@ -5,6 +5,9 @@ use super::{
// ---------------------------------------------------------------- tools
/// Most cells (rows times compared columns) one Remove Duplicates call reads.
const MAX_COMPARED_CELLS: u64 = 50_000_000;
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
let d = s.doc()?;
@@ -24,6 +27,10 @@ pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
}
// Rows are compared one by one, so a sparse sheet's enclosing rectangle is bounded like a list.
if r.count() > MAX_COMPARED_CELLS {
return Err(bad("data.removeDuplicates", "the range is too large"));
}
let start = r.start.row + header as u32;
let mut removed = 0;
edit(s, |cx| {
+72 -18
View File
@@ -18,21 +18,71 @@ fn op_param(s: Option<&str>) -> CfOperator {
}
}
/// `r` minus `cut`, as up to four non-overlapping rectangles.
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
let Some(i) = r.intersection(cut) else { return vec![r] };
let mut out = Vec::new();
if i.start.row > r.start.row {
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
/// Removes a set of cuts from one validation range in a row sweep, updating the result once
/// instead of fragmenting it again for every cut.
fn subtract_ranges(r: RangeRef, cuts: &[RangeRef]) -> Vec<RangeRef> {
let mut events = std::collections::BTreeMap::<u64, Vec<((u32, u32), bool)>>::new();
for cut in cuts {
let Some(i) = r.intersection(cut) else { continue };
let columns = (i.start.col, i.end.col);
events.entry(u64::from(i.start.row)).or_default().push((columns, true));
events.entry(u64::from(i.end.row) + 1).or_default().push((columns, false));
}
if i.end.row < r.end.row {
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
if events.is_empty() {
return vec![r];
}
if i.start.col > r.start.col {
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
}
if i.end.col < r.end.col {
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
events.entry(u64::from(r.end.row) + 1).or_default();
let mut active = std::collections::BTreeMap::<(u32, u32), usize>::new();
let mut out: Vec<RangeRef> = Vec::new();
let mut last_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
let mut previous_row = u64::from(r.start.row);
for (row, changes) in events {
if previous_row < row {
let start_row = previous_row as u32;
let end_row = (row - 1).min(u64::from(r.end.row)) as u32;
let mut covered = Vec::<(u32, u32)>::new();
for &(start, end) in active.keys() {
match covered.last_mut() {
Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end),
_ => covered.push((start, end)),
}
}
let mut next_col = u64::from(r.start.col);
let mut remaining = Vec::new();
for (start, end) in covered {
if next_col < u64::from(start) {
remaining.push((next_col as u32, start - 1));
}
next_col = u64::from(end) + 1;
}
if next_col <= u64::from(r.end.col) {
remaining.push((next_col as u32, r.end.col));
}
for (start_col, end_col) in remaining {
let columns = (start_col, end_col);
if let Some(&index) = last_by_columns.get(&columns)
&& out[index].end.row.saturating_add(1) == start_row
{
out[index].end.row = end_row;
} else {
let index = out.len();
out.push(RangeRef::new(CellRef::new(start_row, start_col), CellRef::new(end_row, end_col)));
last_by_columns.insert(columns, index);
}
}
}
for (columns, add) in changes {
if add {
*active.entry(columns).or_default() += 1;
} else if let Some(count) = active.get_mut(&columns) {
*count -= 1;
if *count == 0 {
active.remove(&columns);
}
}
}
previous_row = row;
}
out
}
@@ -43,9 +93,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
for dv in validations.iter_mut() {
let before = RangeRef::top_left(&dv.ranges);
for cut in cuts {
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
}
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_ranges(*r, cuts)).collect();
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) {
dv.f1 = move_rule_formula(&dv.f1, from, to);
dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to));
@@ -289,11 +337,17 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
let sheet = d.wb.active_sheet();
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let mut bad_cells = Vec::new();
// One scan allowance for the whole command: duplicate or overlapping ranges don't multiply it.
let mut budget = 100_000usize;
// Cells were left unchecked because the allowance ran out: said in the result.
let mut truncated = false;
for dv in &sh.validations {
for r in &dv.ranges {
let r = sh.used_range().and_then(|u| r.intersection(&u));
let Some(r) = r else { continue };
for c in r.iter().take(100_000) {
truncated |= r.count() > budget as u64;
for c in r.iter().take(budget) {
budget -= 1;
let text = sh.input_text(c);
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
bad_cells.push(c.a1());
@@ -301,5 +355,5 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
}
}
}
Ok(json!({"invalid": bad_cells}))
Ok(json!({"invalid": bad_cells, "truncated": truncated}))
}
+6 -1
View File
@@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
let spans = sh.filter_row_spans();
if spans.is_empty() {
return Default::default();
}
let filtered = |row: u32| spans.iter().any(|&(first, last)| row >= first && row <= last);
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
+4
View File
@@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
let skip = cx.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, *r)).unwrap_or_default();
for at in r.iter() {
if skip.contains(&at.row) {
continue;
}
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
let base = match by_style.get(&style) {
Some(c) => c.clone(),
+126 -16
View File
@@ -1,8 +1,8 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
target_sheet,
Cell, CellRef, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param,
target_ranges, target_sheet,
};
// ---------------------------------------------------------------- clipboard
@@ -15,7 +15,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let mut selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let trim = |range: RangeRef| {
@@ -31,7 +31,27 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
let range = trim(ranges.first().copied().unwrap_or_default());
// A copied range with rows a filter hides takes only the rows showing: they are copied
// like a multi-area selection stacked top to bottom (formulas as values), as in Excel.
// A cut still takes every row.
let hidden = if cut { Default::default() } else { super::filtered_rows(sh, range) };
let visible = if hidden.is_empty() { Vec::new() } else { visible_row_runs(range, &hidden) };
let any_visible = !visible.is_empty();
let packed = match visible.as_slice() {
[] => (d.wb.clone(), range, Vec::new()),
[only] => (d.wb.clone(), *only, Vec::new()),
_ => {
let (wb, block) = slide_together(&d.wb, sheet, &visible).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, visible)
}
};
// Whole rows copied with some hidden by a filter come as the block of rows showing, so
// Insert Copied Cells makes room for those rows, not for the hidden ones too.
if !hidden.is_empty() && any_visible && selected.is_full_rows() {
selected = RangeRef::rows(packed.1.start.row, packed.1.end.row);
}
packed
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
@@ -46,6 +66,56 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// The runs of consecutive rows of `range` not in `hidden`, as ranges of `range`'s columns.
fn visible_row_runs(range: RangeRef, hidden: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut runs = Vec::new();
let mut start = None;
for row in range.start.row..=range.end.row {
match (hidden.contains(&row), start) {
(false, None) => start = Some(row),
(true, Some(first)) => {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(row - 1, range.end.col)));
start = None;
}
_ => {}
}
}
if let Some(first) = start {
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
runs
}
/// The validation rules of `src` cut into the areas of a packed block: one rule per area a rule
/// touches, over that area's part moved by the area's shift. `sorted` areas run in order.
fn pack_validations(src: &gridcraft_model::Sheet, sorted: &[RangeRef], shifts: &[(i64, i64)], across: bool) -> Vec<gridcraft_model::Validation> {
let key = |c: CellRef| if across { c.col } else { c.row };
let mut packed_rules = Vec::new();
for dv in &src.validations {
let Some(from) = dv.ranges.first().map(|r| r.start) else { continue };
// The areas each of the rule's ranges touches lie side by side in the sorted list.
let end_key = |a: &RangeRef| if across { a.end.col } else { a.end.row };
let mut by_area: std::collections::BTreeMap<usize, Vec<RangeRef>> = Default::default();
for r in &dv.ranges {
let first = sorted.partition_point(|a| end_key(a) < key(r.start));
for (i, a) in sorted.iter().enumerate().skip(first).take_while(|(_, a)| key(a.start) <= end_key(r)) {
let (dr, dc) = shifts[i];
let moved = r.intersection(a).and_then(|x| Some(RangeRef::new(x.start.offset(dr, dc)?, x.end.offset(dr, dc)?)));
by_area.entry(i).or_default().extend(moved);
}
}
for ranges in by_area.into_values() {
let Some(to) = ranges.first().map(|r| r.start) else { continue };
let mut rule = dv.clone();
rule.f1 = super::super::data::move_rule_formula(&dv.f1, from, to);
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, from, to));
rule.ranges = ranges;
packed_rules.push(rule);
}
}
packed_rules
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
@@ -80,17 +150,35 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.hyperlinks.retain(|c, _| !block.contains(*c));
super::super::data::remove_validation_cells(&mut dst.validations, &[block]);
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
// Where each area lands: the shift of its cells, and its first row or column in the block.
let key = |c: CellRef| if across { c.col } else { c.row };
let mut next = if across { anchor.col } else { anchor.row };
let mut shifts: Vec<(i64, i64)> = Vec::with_capacity(sorted.len());
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let to = i64::from(next);
shifts.push(if across { (0, to - i64::from(a.start.col)) } else { (to - i64::from(a.start.row), 0) });
next = next.saturating_add(if across { a.width() } else { a.height() });
}
// The areas never overlap and run in order, so the area holding a cell is found by search:
// each map below is read once, whatever the number of areas.
let area_of = |c: CellRef| -> Option<usize> {
let i = sorted.partition_point(|a| key(a.start) <= key(c)).checked_sub(1)?;
sorted.get(i)?.contains(c).then_some(i)
};
let moved = |c: CellRef| -> Option<CellRef> {
let (dr, dc) = shifts[area_of(c)?];
c.offset(dr, dc)
};
for (a, &(dr, dc)) in sorted.iter().zip(&shifts) {
for (c, _) in src.cells.iter_range(*a) {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
@@ -98,17 +186,38 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for c in src.spill.keys().copied() {
let Some(to) = moved(c) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Links and validation rules follow their cells too, so a row the filter hides leaves its own
// behind.
for (c, link) in &src.hyperlinks {
if let Some(to) = moved(*c) {
dst.hyperlinks.insert(to, link.clone());
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
}
let packed_rules = pack_validations(src, &sorted, &shifts, across);
for (c, comment) in &src.comments {
if let Some(to) = moved(*c) {
dst.comments.insert(to, comment.clone());
}
}
for m in &src.merges {
let Some(i) = area_of(m.start).filter(|&i| sorted[i].contains_range(m)) else { continue };
let (dr, dc) = shifts[i];
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
@@ -120,6 +229,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
dst.validations.extend(packed_rules);
Some((out, block))
}
+2 -1
View File
@@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Js
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
let skip = s.doc()?.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, r)).unwrap_or_default();
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
crate::fill::fill_except(cx, sheet, src, r, crate::fill::FillMode::Copy, &skip)?;
Ok(Json::Null)
})
}
+147 -29
View File
@@ -14,8 +14,13 @@ fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool {
}
/// `*`/`?` wildcard match with `~` escapes.
///
/// The pattern runs as a bit-parallel automaton over the text: one pass, each character costing
/// one step per 64 pattern positions. Backtracking to the last `*` after a mismatch rescans the
/// text and costs text × pattern comparisons on a long, repetitive cell, for every cell a
/// filter or Find visits.
pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
let t: Vec<char> = text.chars().collect();
// The pattern as (character, escaped) items, runs of `*` folded into one.
let mut p: Vec<(char, bool)> = Vec::new();
let mut it = pat.chars();
while let Some(c) = it.next() {
@@ -23,32 +28,85 @@ pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
if let Some(n) = it.next() {
p.push((n, true));
}
} else {
} else if c != '*' || p.last() != Some(&('*', false)) {
p.push((c, false));
}
}
let (mut ti, mut pi) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == ('*', false) {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
ti = mark;
} else {
return false;
let words = (p.len() + 1).div_ceil(64);
let bit = |set: &mut [u64], i: usize| set[i / 64] |= 1 << (i % 64);
// Positions holding a `*` and a `?`, and where each literal character sits in the pattern.
let mut stars = vec![0u64; words];
let mut any = vec![0u64; words];
let mut at: std::collections::HashMap<char, Vec<usize>> = std::collections::HashMap::new();
for (i, item) in p.iter().enumerate() {
match item {
('*', false) => bit(&mut stars, i),
('?', false) => bit(&mut any, i),
(c, _) => at.entry(*c).or_default().push(i),
}
}
while pi < p.len() && p[pi] == ('*', false) {
pi += 1;
// A literal's bit mask is built when the text first reads it, and kept for at most
// `MAX_CACHED_MASKS` literals: a long pattern of mostly distinct characters would otherwise
// allocate a mask of the pattern's width for each one up front. When there are more literals
// than that, the ones kept are those at the most pattern positions (the dearest to rebuild);
// each of the rest has at most pattern length / `MAX_CACHED_MASKS` positions, so rebuilding
// its mask in `scratch` costs less than one step of the scan.
const MAX_CACHED_MASKS: usize = 256;
let cacheable: Option<std::collections::HashSet<char>> = (at.len() > MAX_CACHED_MASKS).then(|| {
let mut by_count: Vec<(&char, usize)> = at.iter().map(|(c, v)| (c, v.len())).collect();
by_count.sort_unstable_by_key(|&(_, n)| std::cmp::Reverse(n));
by_count.into_iter().take(MAX_CACHED_MASKS).map(|(c, _)| *c).collect()
});
let mut masks: std::collections::HashMap<char, Vec<u64>> = std::collections::HashMap::new();
let mut scratch = vec![0u64; words];
// A set bit i: the text read so far matches the first i pattern items. A `*` matches nothing
// as well, so a state at a `*` is also at the item after it.
let close = |set: &mut Vec<u64>| {
let mut carry = 0u64;
for w in 0..words {
let s = set[w] & stars[w];
let next = (s << 1) | carry;
carry = s >> 63;
set[w] |= next;
}
};
let mut state = vec![0u64; words];
bit(&mut state, 0);
close(&mut state);
let mut next = vec![0u64; words];
for ch in text.chars() {
let lit: Option<&[u64]> = match at.get(&ch) {
None => None,
Some(positions) if cacheable.as_ref().is_none_or(|k| k.contains(&ch)) => {
let mask = masks.entry(ch).or_insert_with(|| {
let mut m = vec![0u64; words];
positions.iter().for_each(|&i| bit(&mut m, i));
m
});
Some(mask)
}
Some(positions) => {
scratch.fill(0);
positions.iter().for_each(|&i| bit(&mut scratch, i));
Some(&scratch)
}
};
let mut carry = 0u64;
let mut alive = 0u64;
for w in 0..words {
// Items that read `ch` advance; a `*` reads it and stays.
let m = state[w] & (any[w] | lit.map_or(0, |l| l[w]));
next[w] = (m << 1) | carry | (state[w] & stars[w]);
carry = m >> 63;
alive |= next[w];
}
if alive == 0 {
return false;
}
std::mem::swap(&mut state, &mut next);
close(&mut state);
}
pi == p.len()
state[p.len() / 64] & (1 << (p.len() % 64)) != 0
}
pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
@@ -112,9 +170,19 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
/// Most characters a cell holds (Excel's limit).
const REPLACE_CELL_MAX_CHARS: usize = 32_767;
/// Longest text one replacement may give a cell, in bytes: the character limit, each up to four
/// bytes. Every occurrence copies the replacement text, so a short cell full of matches and a
/// long replacement would otherwise expand to gigabytes. The character limit is checked on what
/// fits in this.
const REPLACE_CELL_MAX_BYTES: usize = REPLACE_CELL_MAX_CHARS * 4;
/// Most bytes of replaced text one Replace All builds across all its cells.
const REPLACE_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
let with = str_param(p, "with").unwrap_or("").to_string();
let with = str_param(p, "with").unwrap_or("");
if what.is_empty() {
return Err(bad("edit.replace", "missing `what`"));
}
@@ -128,9 +196,14 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
let mut n = 0;
let mut total = 0usize;
for si in sheets {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
// A hidden formula's source is not searched: a count or error would reveal its text.
cx.wb
.sheet(si)
.map(|sh| sh.cells.iter().filter(|(c, cell)| cell.formula.is_none() && cell.py.is_none() || !crate::display::formula_hidden(&cx.wb, sh, *c)).map(|(c, _)| (c, sh.input_text(c))).collect())
.unwrap_or_default();
for (c, text) in cells {
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
continue;
@@ -139,12 +212,20 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
continue;
}
let new = if whole {
with.clone()
(with.len() <= REPLACE_CELL_MAX_BYTES).then(|| with.to_string())
} else if case {
text.replace(&what, &with)
replace_cs_within(&text, &what, with, REPLACE_CELL_MAX_BYTES)
} else {
replace_ci(&text, &what, &with)
replace_ci_within(&text, &what, with, REPLACE_CELL_MAX_BYTES)
};
let new = new.filter(|n| n.chars().count() <= REPLACE_CELL_MAX_CHARS);
let Some(new) = new else {
return Err(EngineError::Other(format!("The replacement makes the text of {} too long.", c.a1())));
};
total = total.saturating_add(new.len());
if total > REPLACE_TOTAL_MAX_BYTES {
return Err(EngineError::Other("The replacements make too much text. Replace in a smaller range.".into()));
}
// A Python cell's text is its code: the replacement edits the code and the cell
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
@@ -199,7 +280,37 @@ fn folds_in_place(s: &str) -> bool {
})
}
#[cfg(test)]
pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
replace_ci_within(text, what, with, usize::MAX).unwrap_or_default()
}
/// `text` with every `what` replaced by `with`, or `None` once the result passes `limit` bytes
/// (checked before each copy, so nothing larger is allocated).
pub(super) fn replace_cs_within(text: &str, what: &str, with: &str, limit: usize) -> Option<String> {
if what.is_empty() {
return (text.len() <= limit).then(|| text.to_string());
}
let mut out = String::new();
let mut i = 0;
for (pos, m) in text.match_indices(what) {
if out.len() + (pos - i) + with.len() > limit {
return None;
}
out.push_str(text.get(i..pos).unwrap_or(""));
out.push_str(with);
i = pos + m.len();
}
let rest = text.get(i..).unwrap_or("");
if out.len() + rest.len() > limit {
return None;
}
out.push_str(rest);
Some(out)
}
/// Case-insensitive [`replace_cs_within`].
pub(super) fn replace_ci_within(text: &str, what: &str, with: &str, limit: usize) -> Option<String> {
let lower = text.to_lowercase();
let w = what.to_lowercase();
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
@@ -207,15 +318,22 @@ pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
return text.replace(what, with);
return replace_cs_within(text, what, with, limit);
}
let mut out = String::new();
let mut i = 0;
while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) {
if out.len() + pos + with.len() > limit {
return None;
}
out.push_str(text.get(i..i + pos).unwrap_or(""));
out.push_str(with);
i += pos + w.len();
}
out.push_str(text.get(i..).unwrap_or(""));
out
let rest = text.get(i..).unwrap_or("");
if out.len() + rest.len() > limit {
return None;
}
out.push_str(rest);
Some(out)
}
+111 -50
View File
@@ -17,11 +17,13 @@ pub(in super::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`")) };
let d = s.doc()?;
let mut names = Resolver::new(&d.wb);
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
let mut found = Vec::new();
let Some((sheet, _, by_name)) = names.target(d.wb.active_sheet(), part, 0, Some(&mut found)) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
@@ -77,57 +79,114 @@ pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
/// Resolves Go To references against one workbook. Defined names are found through an index
/// built once, and whether a name refers to a reference is remembered: listing the names of a
/// workbook full of names that alias other names would otherwise scan every name for every alias
/// of every name.
struct Resolver<'a> {
wb: &'a gridcraft_model::Workbook,
/// Indexes into `wb.names` by scope and folded name, in workbook order.
by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>>,
/// [`Resolver::name_areas`] without areas, by name index, sheet and depth: the target sheet
/// and the number of areas.
memo: std::collections::HashMap<(usize, usize, usize), Option<(usize, usize)>>,
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
impl<'a> Resolver<'a> {
fn new(wb: &'a gridcraft_model::Workbook) -> Self {
let mut by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>> = std::collections::HashMap::new();
for (i, n) in wb.names.iter().enumerate() {
by_name.entry((n.scope, gridcraft_formula::fold_name(&n.name))).or_default().push(i);
}
Self { wb, by_name, memo: std::collections::HashMap::new() }
}
/// The first name of scope `scope` that `same` accepts, as a scan of `wb.names` finds it.
/// Names `same` accepts fold alike, so only that bucket is checked.
fn find(&self, scope: Option<usize>, body: &str, same: impl Fn(&str, &str) -> bool) -> Option<usize> {
self.by_name
.get(&(scope, gridcraft_formula::fold_name(body)))?
.iter()
.copied()
.find(|&i| self.wb.names.get(i).is_some_and(|n| same(&n.name, body)))
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, how many
/// areas (appended to `areas` when given), and whether a defined name or a table gave them.
/// `Sheet2!Name` is the name scoped to Sheet2. A name counts when what it refers to is a
/// reference; a constant or a formula is not one.
fn target(&mut self, sheet: usize, part: &str, depth: usize, areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize, bool)> {
let wb = self.wb;
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
if let Some(a) = areas {
a.push(r);
}
return Some((on, 1, false));
}
// As `Workbook::name`: the sheet's own name, else the workbook's.
let name = match prefix {
Some(_) => self.find(Some(on), body, gridcraft_formula::same_name),
None => self.find(Some(sheet), body, str::eq_ignore_ascii_case).or_else(|| self.find(None, body, str::eq_ignore_ascii_case)),
};
if let Some(i) = name {
return self.name_areas(i, sheet, depth, areas).map(|(t, n)| (t, n, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
if let Some(a) = areas {
a.push(t.data_range().unwrap_or(t.range));
}
return Some((si, 1, true));
}
None
}
/// The sheet and number of areas defined name `wb.names[i]` refers to, seen from sheet
/// `sheet`, when that is a reference; the areas are appended to `areas` when given.
fn name_areas(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
let key = (i, sheet, depth);
if areas.is_none()
&& let Some(known) = self.memo.get(&key)
{
return *known;
}
let out = self.name_areas_uncached(i, sheet, depth, areas.as_deref_mut());
if areas.is_none() {
self.memo.insert(key, out);
}
out
}
fn name_areas_uncached(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
let n = self.wb.names.get(i)?;
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut count = 0usize;
for p in reference_parts(formula) {
let (s, found, _) = self.target(from, p, depth + 1, areas.as_deref_mut())?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
count += found;
if count > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, count))
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
@@ -137,11 +196,13 @@ fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName,
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut names = Resolver::new(wb);
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
.enumerate()
.filter(|(i, n)| !n.hidden && names.name_areas(*i, sheet, 0, None).is_some())
.filter_map(|(_, n)| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
+452 -248
View File
@@ -4,74 +4,7 @@ use super::{
target_sheet,
};
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
}
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
let operand = new.value.clone();
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
&& (operand.is_number() || operand.is_empty())
{
new.value = match op {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
"divide" => Value::number(x / y),
_ => operand.clone(),
};
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
new.value = old.value.clone();
}
if let Some(formula) = &old.formula
&& let Ok(y) = operand.to_number()
&& (operand.is_number() || operand.is_empty())
&& let Some(symbol) = operation_symbol(op)
{
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
fn operation_symbol(op: &str) -> Option<char> {
match op {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
}
fn copy_merges(
cx: &mut crate::cmd::Ctx,
dest_sheet: usize,
src_sheet: &gridcraft_model::Sheet,
src: RangeRef,
at: CellRef,
what: What,
transpose: bool,
) -> Result<()> {
if !matches!(what, What::All | What::Formats) || transpose {
return Ok(());
}
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
for merge in merges {
let row_offset = at.row as i64 - src.start.row as i64;
let col_offset = at.col as i64 - src.start.col as i64;
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
let moved = RangeRef::new(start, end);
let sheet = cx.sheet_mut(dest_sheet)?;
sheet.merges.retain(|existing| !existing.intersects(&moved));
sheet.merges.push(moved);
}
}
Ok(())
}
#[allow(clippy::cognitive_complexity)] // The command applies the selected paste mode in one atomic edit.
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let what = match str_param(p, "what").unwrap_or("all") {
@@ -97,7 +30,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
let src = clip.range;
// Whole columns or rows paste whole (the copy keeps only their used part as its text), so
// what the destination columns hold further down is replaced too, as in Excel. The cells are
// visited sparsely below, so this costs what the columns hold, not 1,048,576 rows.
let whole = clip.areas.is_empty() && (clip.selected.is_full_cols() || clip.selected.is_full_rows()) && clip.selected.contains_range(&clip.range);
let src = if whole { clip.selected } else { clip.range };
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
// Tile the copy over a larger destination that is a multiple of its size.
let tile = sel.height() % h == 0
@@ -108,17 +45,31 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
// A single cell or row repeated down a filtered selection skips the rows the filter hides; a
// taller block is pasted straight down, hidden rows included, as in Excel.
let skip_rows = match s.doc()?.wb.sheet(dest_sheet) {
Some(sh) if tile && h == 1 => super::filtered_rows(sh, sel),
_ => Default::default(),
};
// Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole
// rows right of column A, a whole column transposed) instead of pasting part of it.
if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() {
return Err(EngineError::Other("The copied cells don't fit here: paste them where the whole copied area fits on the sheet.".into()));
}
// Paste Link writes a formula into every cell, blank ones included, so it is the one paste
// whose work grows with the area rather than with what the source holds.
let link_src = clip.range;
if link && link_src.count().saturating_mul(u64::from(tiles_r) * u64::from(tiles_c)) > MAX_PASTE_LINK_CELLS {
return Err(EngineError::Other("Too many cells to paste as links at once.".into()));
}
let same_doc = s.doc()?.uid == clip.doc_uid;
let cut_move = clip.cut && same_doc && what == What::All;
let src_wb = clip.wb.clone();
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
let src_sheet_name = src_sheet.name.clone();
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
}
edit(s, |cx| {
// Styles from another workbook must be re-interned.
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
let mut style_map = std::collections::HashMap::new();
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
if same_doc {
@@ -126,7 +77,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
};
let mut moved_formulas = Vec::new();
let mut moved_formulas = std::collections::BTreeMap::new();
let mut moved_pictures = std::collections::BTreeMap::new();
if cut_move {
// Remove the source first (contents and formats), and fix references to it.
@@ -143,190 +94,284 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
moved_formulas = taken;
moved_formulas = taken.into_iter().collect();
}
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
// cells its `xl()` references named on its original sheet, as a moved formula does:
// they are qualified with that sheet before the workbook-wide move adjustment below.
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
let Some(py) = cell.py.clone() else { return Ok(None) };
if !cut_move || dest_sheet == clip.sheet {
return Ok(Some(py));
}
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
});
Some(gridcraft_formula::print(&e))
// The cells of the copied block that hold something (or that a link reads), as offsets
// inside it. Blank source cells matter only where the destination holds something.
let source_offsets: std::collections::BTreeSet<(u32, u32)> = if link {
(0..link_src.height()).flat_map(|r| (0..link_src.width()).map(move |c| (r, c))).collect()
} else {
let offset = |c: CellRef| (c.row - src.start.row, c.col - src.start.col);
let mut held: std::collections::BTreeSet<(u32, u32)> = src_sheet.cells.iter_range(src).map(|(c, _)| offset(c)).collect();
let side =
src_sheet.spill.keys().chain(src_sheet.cell_pictures.keys()).chain(src_sheet.comments.keys()).chain(src_sheet.hyperlinks.keys());
held.extend(side.chain(moved_formulas.keys()).chain(moved_pictures.keys()).filter(|c| src.contains(**c)).map(|c| offset(*c)));
held
};
let skip_cells = matches!(what, What::Validation | What::ColumnWidths);
// A blank source cell clears (or reformats) a destination cell that holds something, so
// those cells join the visit. One scan of the whole pasted area sorts them by tile.
let mut dest_held: std::collections::BTreeMap<(u32, u32), Vec<(u32, u32)>> = Default::default();
if !link && !skip_cells {
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
let held =
cx.wb.sheet(dest_sheet).into_iter().flat_map(|sh| {
sh.cells.iter_range(pasted).map(|(c, _)| c).chain(sh.cell_pictures.keys().copied().filter(|c| pasted.contains(*c)))
});
Ok(Some(match qualified {
Some(code) => {
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
check_python_fits(&moved, &dest_name, dest)?;
std::sync::Arc::new(moved)
for d in held.collect::<Vec<_>>() {
let (dr, dc) = (d.row - at.row, d.col - at.col);
let local = (dr % h, dc % w);
dest_held.entry((dr / h, dc / w)).or_default().push(if transpose { (local.1, local.0) } else { local });
}
}
let no_cells = std::collections::BTreeSet::new();
for tr in 0..tiles_r {
for tc in 0..tiles_c {
let merged;
let offsets = match dest_held.get(&(tr, tc)) {
Some(extra) => {
merged = source_offsets.iter().chain(extra).copied().collect::<std::collections::BTreeSet<_>>();
&merged
}
None => py,
}))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
None if skip_cells => &no_cells,
None => &source_offsets,
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new.py = None;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
for &(r, c) in offsets {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
if skip_rows.contains(&dest.row) {
continue;
}
let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } else { src_sheet.cell(sc).cloned() };
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks
&& src_picture.is_none()
&& src_val.is_empty()
&& src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none())
{
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
let Some(py) = cell.py.clone() else { return Ok(None) };
if !cut_move || dest_sheet == clip.sheet {
return Ok(Some(py));
}
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
});
Some(gridcraft_formula::print(&e))
});
Ok(Some(match qualified {
Some(code) => {
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
check_python_fits(&moved, &dest_name, dest)?;
std::sync::Arc::new(moved)
}
None => py,
}))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
st
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new.py = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
new.py = moved_py(&sc_cell)?;
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.py = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
&& new.py.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" => {
if y == 0.0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(x / y)
}
}
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
&& new.py.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
// Numbers stored as text participate; other text and errors stay as they are.
apply_operation(&op, &old, &mut new);
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
}
// 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)?;
crate::cmd::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 = RangeRef::top_left(&dv.ranges).unwrap_or(sc);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
paste_validations(cx, src_sheet, src, dest_sheet, pasted, PasteMode { transpose, skip_blanks, skip_rows: &skip_rows })?;
}
if what == What::ColumnWidths {
// Each pasted column takes the width of the column copied to it (transposed, the
// copied rows land in columns and all take the last copied column's width).
let cols = cx.sheet_mut(dest_sheet)?;
for dc in 0..pasted.width() {
let info = if transpose {
(0..src.width()).rev().find_map(|c| src_sheet.cols.get(&(src.start.col + c)).copied())
} else {
src_sheet.cols.get(&(src.start.col + dc % w)).copied()
};
if let Some(info) = info {
cols.cols.insert(pasted.start.col + dc, info);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
// Merged areas inside the copied block travel with it.
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
if matches!(what, What::All | What::Formats) && !transpose {
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
for m in merges {
let dr = at.row as i64 - src.start.row as i64;
let dc = at.col as i64 - src.start.col as i64;
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
let nm = RangeRef::new(a, b);
let sh = cx.sheet_mut(dest_sheet)?;
sh.merges.retain(|x| !x.intersects(&nm));
sh.merges.push(nm);
}
}
}
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
let e = if dest_sheet == clip.sheet {
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
} else {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name.clone(), to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
cx.structural = true;
@@ -351,6 +396,161 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"pasted": s.doc()?.selection.a1()}))
}
/// Most cells one Paste Link writes (it writes a formula into every cell of the copied area,
/// blank ones included).
const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
/// Most validation ranges one paste adds (a rule covering part of a small block tiled over a huge
/// selection makes one range per tile).
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
/// How a validation paste lays the rules out: transposed or not, keeping the destination's rule under
/// blank source cells, and leaving the rows a filter hides as they are.
#[derive(Clone, Copy)]
struct PasteMode<'a> {
transpose: bool,
skip_blanks: bool,
skip_rows: &'a std::collections::BTreeSet<u32>,
}
/// `range` less the rows in `skip`: the runs of rows between them, as ranges of its columns.
fn without_rows(range: RangeRef, skip: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
let mut out = Vec::new();
let mut next = range.start.row;
for &row in skip.range(range.start.row..=range.end.row) {
if row > next {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(row - 1, range.end.col)));
}
next = row + 1;
}
if next <= range.end.row {
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(range.end.row, range.end.col)));
}
out
}
/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the
/// destination's rules (cells whose source had no rule end up with none), then each source rule
/// that covers part of the copied block `src` comes along as one rule over the translated parts.
/// `size` is one tile's (height, width) at the destination; `pasted` is all the tiles together.
fn paste_validations(
cx: &mut crate::cmd::Ctx<'_>,
src_sheet: &gridcraft_model::Sheet,
src: RangeRef,
dest_sheet: usize,
pasted: RangeRef,
mode: PasteMode<'_>,
) -> Result<()> {
let PasteMode { transpose, skip_blanks, skip_rows } = mode;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1));
let mut rules = Vec::new();
// Under Skip Blanks the destination keeps its rule below a blank source cell that has none, so
// only the destination of cells that hold something (and of the rules) is cleared. Nothing is
// collected when no destination rule is under the paste.
let mut cleared: Vec<RangeRef> = Vec::new();
let dest_rules =
cx.wb.sheet(dest_sheet).is_some_and(|sh| sh.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.intersection(&pasted).is_some())));
if skip_blanks && dest_rules {
let mut cells = Vec::new();
for (sc, cell) in src_sheet.cells.iter_range(src) {
if cell.formula.is_none() && cell.py.is_none() && src_sheet.value(sc).is_empty() {
continue;
}
let (r, c) = (sc.row - src.start.row, sc.col - src.start.col);
let (r, c) = if transpose { (c, r) } else { (r, c) };
for tr in 0..tiles_r {
for tc in 0..tiles_c {
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c))
&& !skip_rows.contains(&d.row)
{
cells.push((d.row, d.col));
}
}
}
}
// Coalesce populated cells into horizontal runs, then join identical runs in adjacent
// rows. The range limit applies to the cuts, not the number of populated cells.
cells.sort_unstable();
let mut runs: Vec<RangeRef> = Vec::new();
let mut i = 0;
while i < cells.len() {
let (row, start_col) = cells[i];
let mut end_col = start_col;
i += 1;
while i < cells.len() && cells[i].0 == row && cells[i].1 <= end_col.saturating_add(1) {
end_col = end_col.max(cells[i].1);
i += 1;
}
runs.push(RangeRef::new(CellRef::new(row, start_col), CellRef::new(row, end_col)));
}
let mut last_run_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
for run in runs {
let columns = (run.start.col, run.end.col);
if let Some(&index) = last_run_by_columns.get(&columns)
&& cleared[index].end.row.saturating_add(1) == run.start.row
{
cleared[index].end.row = run.end.row;
} else {
let index = cleared.len();
cleared.push(run);
last_run_by_columns.insert(columns, index);
}
}
}
let mut total = 0usize;
for dv in &src_sheet.validations {
let parts: Vec<RangeRef> = dv.ranges.iter().filter_map(|r| r.intersection(&src)).collect();
let Some(first) = dv.ranges.first().map(|r| r.start) else { continue };
if parts.is_empty() {
continue;
}
let mut ranges = Vec::new();
if parts.contains(&src) {
ranges.push(pasted);
} else {
total = total.saturating_add(parts.len().saturating_mul(tiles_r as usize).saturating_mul(tiles_c as usize));
if total > MAX_PASTE_VALIDATION_RANGES {
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
}
for tr in 0..tiles_r {
for tc in 0..tiles_c {
let Some(origin) = pasted.start.offset(i64::from(tr * h), i64::from(tc * w)) else { continue };
for p in &parts {
let (r0, c0) = (p.start.row - src.start.row, p.start.col - src.start.col);
let (r1, c1) = (p.end.row - src.start.row, p.end.col - src.start.col);
let ((r0, c0), (r1, c1)) = if transpose { ((c0, r0), (c1, r1)) } else { ((r0, c0), (r1, c1)) };
let Some(a) = origin.offset(i64::from(r0), i64::from(c0)) else { continue };
let b = origin.offset_clamped(i64::from(r1), i64::from(c1));
ranges.push(RangeRef::new(a, b));
}
}
}
}
// The rows a filter hides keep what they have.
let ranges: Vec<RangeRef> = ranges.into_iter().flat_map(|r| without_rows(r, skip_rows)).collect();
let Some(anchor) = ranges.first().map(|r| r.start) else { continue };
let mut rule = dv.clone();
// Relative references move with the rule, as pasted formulas do: they are written for the
// rule's first cell.
rule.f1 = super::super::data::move_rule_formula(&dv.f1, first, anchor);
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, first, anchor));
rule.ranges = ranges;
rules.push(rule);
}
if cleared.len() > MAX_PASTE_VALIDATION_RANGES {
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
}
let sh = cx.sheet_mut(dest_sheet)?;
if skip_blanks {
cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied()));
super::super::data::remove_validation_cells(&mut sh.validations, &cleared);
} else {
super::super::data::remove_validation_cells(&mut sh.validations, &without_rows(pasted, skip_rows));
}
sh.validations.extend(rules);
Ok(())
}
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
use gridcraft_formula::adjust::Edit;
@@ -367,19 +567,19 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
let Some(sheet) = wb.sheet_mut(si) else { continue };
for (c, f) in keys {
let Some(expr) = f.expr() else { continue };
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
let text = gridcraft_formula::print(&new);
// Borrows the tree and copies and prints only the formulas the edit changes. A formula
// the edit leaves alone keeps its stored text, even if that text is not canonical.
let Some(new) = f.with_expr(|x| gridcraft_formula::adjust::adjust_if_changed(x, &host, target, e)) else { continue };
let array = match f.array {
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
a => a,
};
if (text != f.text || array != f.array)
&& let Some(cell) = sheet.cells.get_mut(c)
{
let mut nf = Formula::from_expr(new);
nf.array = array;
nf.dynamic = f.dynamic;
let nf = match new {
Some(new) => Formula { array, dynamic: f.dynamic, ..Formula::from_expr(new) },
None if array != f.array => Formula { array, ..(*f).clone() },
None => continue,
};
if let Some(cell) = sheet.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
@@ -396,14 +596,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
}
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
/// name and bubble sizes, and the source block it was made from). Every edit that moves
/// name and bubble sizes, and the source block it was made from, and a form control's linked
/// cell). Every edit that moves
/// references calls it: inserting and deleting lines and cells and cut-moves (through
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
/// block that no longer exists is dropped.
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
for si in 0..wb.sheets.len() {
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(sh) = wb.sheet_mut(si) else { continue };
let fix = |text: &str| -> String {
match gridcraft_formula::parse(text) {
@@ -420,6 +621,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
}
}
for c in sh.controls.iter_mut() {
c.link = c.link.as_deref().map(fix);
}
}
}
+8 -1
View File
@@ -66,7 +66,14 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
// Extension moves the far corner, not the active cell.
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
let mut to = if jmp && extend && (dr == 0) != (dc == 0) {
// Excel: Ctrl+Shift+arrow searches the active cell's column (or row) from the moving
// edge, so a header row selected with Ctrl+Shift+Right extends down to the end of the
// active column's data.
let probe = if dr != 0 { CellRef::new(from.row, sel.active.col) } else { CellRef::new(sel.active.row, from.col) };
let edge = jump(sheet, probe, dr.signum(), dc.signum());
if dr != 0 { CellRef::new(edge.row, from.col) } else { CellRef::new(from.row, edge.col) }
} else if jmp {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
+163
View File
@@ -42,6 +42,28 @@ fn go_to_lists_the_named_ranges_of_every_sheet() {
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
}
/// Thousands of names that alias other names are listed with one lookup per alias, not a scan of
/// every name for each; aliases resolve through the chain like a single name does.
#[test]
fn go_to_lists_many_aliases_and_follows_them() {
let mut s = go_to_workbook();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let alias = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
wb.names.push(alias("Chain0".into(), "Areas".into()));
for i in 1..20_000 {
wb.names.push(alias(format!("Chain{i}"), format!("Chain{},price", i - 1)));
}
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
let names: Vec<&str> = list["names"].as_array().unwrap().iter().filter_map(|n| n.as_str()).collect();
// Chain0..Chain6 are within the name depth Go To follows; longer chains are not ranges.
for i in 0..7 {
assert!(names.contains(&format!("Chain{i}").as_str()), "Chain{i} in {names:?}");
}
assert!(!names.contains(&"Chain7"), "{names:?}");
let out = s.execute("edit.goTo", json!({"reference": "chain1"})).unwrap();
assert_eq!(out["selection"], "A1,C3:C4,B2:B5", "{out}");
}
#[test]
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
let mut s = go_to_workbook();
@@ -302,6 +324,103 @@ fn wildcards() {
assert!(wildcard("", "*"));
}
/// The backtracking matcher `wildcard` replaced, as the reference for its results.
fn wildcard_backtracking(text: &str, pat: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let mut p: Vec<(char, bool)> = Vec::new();
let mut it = pat.chars();
while let Some(c) = it.next() {
if c == '~' {
if let Some(n) = it.next() {
p.push((n, true));
}
} else {
p.push((c, false));
}
}
let (mut ti, mut pi) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == ('*', false) {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == ('*', false) {
pi += 1;
}
pi == p.len()
}
#[test]
fn wildcard_agrees_with_backtracking() {
// Every text up to 5 characters over a small alphabet against every pattern up to 4 items.
let alphabet = ['a', 'b', '*', '?', '~'];
let all = |n: usize, chars: &[char]| -> Vec<String> {
let mut out = vec![String::new()];
let mut level = vec![String::new()];
for _ in 0..n {
level = level.iter().flat_map(|s| chars.iter().map(move |c| format!("{s}{c}"))).collect();
out.extend(level.iter().cloned());
}
out
};
let texts = all(5, &['a', 'b', '*']);
for pat in all(4, &alphabet) {
for text in &texts {
assert_eq!(wildcard(text, &pat), wildcard_backtracking(text, &pat), "{text:?} ~ {pat:?}");
}
}
// Patterns longer than one 64-bit word.
let long = format!("*{}*b?", "a".repeat(70));
for text in [format!("x{}yb1", "a".repeat(70)), format!("{}b", "a".repeat(71)), "a".repeat(200)] {
assert_eq!(wildcard(&text, &long), wildcard_backtracking(&text, &long), "{text}");
}
}
#[test]
fn wildcard_with_many_distinct_literals_agrees_with_backtracking() {
// 600 distinct characters in the pattern: more than the masks kept, so the rest are rebuilt.
let chars: Vec<char> = (0x4E00u32..0x4E00 + 600).filter_map(char::from_u32).collect();
let word: String = chars.iter().collect();
let pat = format!("*{word}?{}", chars[3]);
for text in [format!("x{word}yq{}", chars[3]), word.clone(), format!("{word}{word}"), chars.iter().rev().collect::<String>()] {
assert_eq!(wildcard(&text, &pat), wildcard_backtracking(&text, &pat), "{} chars", text.chars().count());
}
assert!(wildcard(&format!("{word}zz{}", chars[3]).replace("zz", "z"), &pat));
}
#[test]
fn wildcard_keeps_masks_of_frequent_literals_past_the_cache_limit() {
// 300 distinct characters come before the repeated `a`: past the cached masks, `a` (5,000 positions)
// still has to be read from a kept mask, or it is rebuilt for each of the 20,000 `a` in the text.
let rare: String = (0x4E00u32..0x4E00 + 300).filter_map(char::from_u32).collect();
let pat = format!("*{rare}{}b", "a".repeat(5_000));
let text = "a".repeat(20_000);
assert!(!wildcard(&text, &pat));
assert!(wildcard(&format!("{rare}{}b", "a".repeat(5_000)), &pat));
assert!(!wildcard(&format!("{rare}{}", "a".repeat(5_000)), &pat));
}
#[test]
fn wildcard_on_long_repetitive_text_is_linear() {
// The backtracking matcher compared about text × pattern characters here (10^8).
let text = "a".repeat(20_000);
let pat = format!("*{}b", "a".repeat(5_000));
assert!(!wildcard(&text, &pat));
assert!(wildcard(&format!("{text}b"), &pat));
}
#[test]
fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
@@ -424,6 +543,50 @@ fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
}
#[test]
fn replace_refuses_text_that_would_grow_past_a_cell() {
// 10,000 matches × a 1,000-character replacement would make a 10 MB cell (and a few such
// cells gigabytes); the replacement is refused before that text is built.
let mut s = Session::new();
s.new_workbook();
let long = "a".repeat(10_000);
s.execute("range.setValues", json!({"range": "A1", "values": [[long.clone()], ["abc"]]})).unwrap();
for case in [false, true] {
let err = s.execute("edit.replace", json!({"what": "a", "with": "x".repeat(1_000), "matchCase": case})).expect_err("too long");
assert!(err.to_string().contains("too long"), "{err}");
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!(long));
assert_eq!(s.execute("cell.get", json!({"cell": "A2"})).unwrap()["value"], json!("abc"));
}
// A replacement that stays within a cell's limit still happens.
s.execute("edit.replace", json!({"what": "b", "with": "x".repeat(100)})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A2"})).unwrap()["value"], json!(format!("a{}c", "x".repeat(100))));
}
#[test]
fn replace_refuses_a_result_past_the_character_limit_though_it_fits_the_byte_limit() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["z"]]})).unwrap();
for whole in [false, true] {
let err = s.execute("edit.replace", json!({"what": "z", "with": "x".repeat(32_768), "wholeCell": whole})).expect_err("32,768 characters");
assert!(err.to_string().contains("too long"), "{err}");
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!("z"));
}
s.execute("edit.replace", json!({"what": "z", "with": "x".repeat(32_767)})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!("x".repeat(32_767)));
}
#[test]
fn bounded_replace_stops_at_its_limit() {
use super::find::{replace_ci_within, replace_cs_within};
assert_eq!(replace_cs_within("abab", "a", "xy", 6).as_deref(), Some("xybxyb"));
assert_eq!(replace_cs_within("abab", "a", "xy", 5), None);
assert_eq!(replace_ci_within("AbAb", "a", "xy", 6).as_deref(), Some("xybxyb"));
assert_eq!(replace_ci_within("AbAb", "a", "xy", 5), None);
assert_eq!(replace_cs_within("abc", "z", "", 3).as_deref(), Some("abc"));
assert_eq!(replace_cs_within("abc", "z", "", 2), None);
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
+3 -3
View File
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
+63 -3
View File
@@ -42,6 +42,15 @@ pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Most cells (rows × columns, blanks included) Get Data imports. The rows are built densely
/// before they are written, so a few sparse records or one far-away CSV cell must not expand
/// into a rectangle of billions of slots.
const MAX_IMPORT_CELLS: u64 = 10_000_000;
fn too_many_cells() -> EngineError {
EngineError::Other(format!("The data is too large to import ({MAX_IMPORT_CELLS} cells at most)."))
}
pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
let bytes = if let Some(t) = str_param(p, "text") {
t.as_bytes().to_vec()
@@ -57,15 +66,27 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
if text.trim_start().starts_with('[') {
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
let mut keys: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for o in &arr {
if let Some(m) = o.as_object() {
for k in m.keys() {
if !keys.contains(k) {
if !seen.contains(k.as_str()) {
// Stop collecting at the column limit.
if keys.len() >= gridcraft_core::MAX_COLS as usize {
return Err(EngineError::Other("The data has more fields than a sheet has columns.".into()));
}
seen.insert(k.as_str());
keys.push(k.clone());
}
}
}
}
if arr.len() as u64 + 1 > u64::from(gridcraft_core::MAX_ROWS) {
return Err(EngineError::Other("The data has more rows than a sheet has.".into()));
}
if (arr.len() as u64 + 1).saturating_mul(keys.len().max(1) as u64) > MAX_IMPORT_CELLS {
return Err(too_many_cells());
}
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
for o in &arr {
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
@@ -75,10 +96,17 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
let delim = str_param(p, "delimiter")
.and_then(|d| d.chars().next())
.unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' });
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() };
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, max_used_cells: Some(MAX_IMPORT_CELLS), ..Default::default() };
// The cell limit is enforced while parsing, before an oversized file is stored.
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| match e {
gridcraft_xlsx::IoError::TooLarge(_) => too_many_cells(),
e => EngineError::Other(e.to_string()),
})?;
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
if u.count() > MAX_IMPORT_CELLS {
return Err(too_many_cells());
}
(u.start.row..=u.end.row)
.map(|r| {
(u.start.col..=u.end.col)
@@ -129,3 +157,35 @@ pub(super) fn queries(s: &mut Session, _: &Json) -> Result<Json> {
.collect(),
))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn s() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
#[test]
fn sparse_imports_are_refused_before_their_rows_are_built() {
let mut s = s();
let sheets = s.doc().unwrap().wb.sheets.len();
// 5,000 records with one field each: 5,001 × 5,000 slots once every record gets every field.
let records: Vec<_> = (0..5_000).map(|i| json!({ format!("k{i}"): i })).collect();
let text = serde_json::to_string(&records).unwrap();
let err = s.execute("data.getData", json!({"text": text})).expect_err("too many cells");
assert!(err.to_string().contains("too large"), "{err}");
// A CSV whose last line reaches 16,001 columns, 1,000 lines down.
let text = format!("1{}{}x", "\n".repeat(999), ",".repeat(16_000));
let err = s.execute("data.fromTextCsv", json!({"text": text})).expect_err("too many cells");
assert!(err.to_string().contains("too large"), "{err}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
// Small imports still work.
let out = s.execute("data.getData", json!({"text": r#"[{"a": 1}, {"b": 2}]"#})).unwrap();
assert_eq!((out["rows"].as_u64(), out["cols"].as_u64()), (Some(3), Some(2)), "{out}");
}
}
+10
View File
@@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
let kind = kind_param(p, "arrange")?;
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
@@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
ObjKind::Shape | ObjKind::Equation => {
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
}
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
};
found.ok_or_else(|| bad("arrange", "no such object"))?;
Ok(Json::Null)
@@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
// Form controls paint above everything else.
for c in &sh.controls {
v.push(
json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}),
);
}
Ok(Json::Array(v))
}
@@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
}
}
@@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
};
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
+2 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Options",
["File"],
None,
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
"{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
always,
options
),
@@ -230,7 +230,7 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
gridcraft_model::CalcMode::Manual => "manual",
};
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change, "precisionAsDisplayed": c.precision_as_displayed})
});
Ok(json!({"calculation": calculation}))
}
+75 -3
View File
@@ -36,13 +36,28 @@ fn max_periods(history: usize) -> u32 {
u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS)
}
/// Most forecast rows when the model is fit to the first `fit` of `history` rows, which span
/// `slots` timeline slots once their gaps are filled. With a Forecast Start inside the history,
/// every row after it is forecast too (a hindcast row holds the same ETS formulas, each refitting
/// the model over the `slots` slots), so those rows (counted from the raw rows) count against
/// [`MAX_WORK`] before any forecast row does. `None` when the hindcast alone exceeds it.
fn max_periods_from(history: usize, fit: usize, slots: usize) -> Option<u32> {
if fit >= history {
return Some(max_periods(history));
}
let f = u64::try_from(slots.max(fit)).unwrap_or(u64::MAX).max(1);
let hindcast = u64::try_from(history - fit).unwrap_or(u64::MAX);
let rest = (MAX_WORK / f).checked_sub(hindcast).filter(|r| *r >= 1)?;
Some(u32::try_from(rest).unwrap_or(u32::MAX).min(MAX_PERIODS))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"data.forecastSheet",
"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), 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.",
"{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, or with a forecastStart inside the history (hindcast rows are forecast too) (the greater of the fitted rows and fitted slots × (periods + rows from forecastStart on, that row included)) ≤ 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
)]
@@ -257,13 +272,23 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
};
let cap = max_periods(rows.len());
// The slots the model is fit over: the fit rows' span of the timeline, gaps included.
let slots = match (fit < rows.len(), times.get(fit.wrapping_sub(1)), times.first()) {
(true, Some(last_fit), Some(first)) => match (timeline.steps_after_last(*last_fit), timeline.steps_after_last(*first)) {
(Some(b), Some(a)) => ((b - a).round() + 1.0).clamp(0.0, 1e9) as usize,
_ => fit,
},
_ => fit,
};
let cap = max_periods_from(rows.len(), fit, slots).ok_or_else(|| {
EngineError::Other("The forecast start is too early for this much history: choose a later forecast start or less history.".into())
})?;
let periods = match end {
Some(end) => steps_to(&timeline, end, cap)?,
None => match number(p, "periods")? {
Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32,
Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))),
None => default_periods(rows.len()),
None => default_periods(rows.len()).min(cap),
},
};
let future: Vec<f64> =
@@ -427,6 +452,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
data_table: None,
gridlines: true,
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
@@ -530,6 +557,51 @@ mod tests {
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
}
#[test]
fn hindcast_rows_count_against_the_work_limit() {
use super::{MAX_WORK, max_periods, max_periods_from};
assert_eq!(max_periods_from(1_300, 1_300, 1_300), Some(max_periods(1_300)));
// 2,000 rows fit to the first 1,899: 101 hindcast rows, then 162 forecast rows.
assert_eq!(max_periods_from(2_000, 1_899, 1_899), Some(162));
// Fit to the first 1,000: the 1,000 hindcast rows alone are twice the limit.
assert_eq!(max_periods_from(2_000, 1_000, 1_000), None);
// 800 rows fit to the first 400, which span 571 slots (gaps filled): each formula refits
// 571 slots, so 475 forecast rows after the 400 hindcast rows, not 850.
assert_eq!(max_periods_from(800, 400, 571), Some(475));
assert!((475 + 400) * 571 <= MAX_WORK as usize);
for (h, f) in [(2_000usize, 1_899usize), (10_000, 9_990), (100, 2), (60, 50)] {
if let Some(p) = max_periods_from(h, f, f) {
assert!((u64::from(p) + (h - f) as u64) * f as u64 <= MAX_WORK, "{h} {f}");
}
}
let mut s = Session::new();
s.new_workbook();
let mut rows = vec![json!(["t", "y"])];
rows.extend((1..=2_000).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5])));
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let sheets = s.doc().unwrap().wb.sheets.len();
let err = s.execute("data.forecastSheet", json!({"range": "A1:B2001", "forecastStart": 1_001})).expect_err("hindcast too long");
assert!(err.to_string().contains("forecast start is too early"), "{err}");
let err = s.execute("data.forecastSheet", json!({"range": "A1:B2001", "forecastStart": 1_900, "periods": 163})).expect_err("past the cap");
assert!(err.to_string().contains("from 1 to 162"), "{err}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
}
#[test]
fn the_hindcast_cap_follows_the_slots_the_fit_spans_not_its_rows() {
// 800 rows, 20% of the slots missing: the first 400 rows span 500 slots, so each ETS
// formula refits 500 slots and the cap is 1,000 - 400 = 600 (it was 850 by rows).
let mut s = Session::new();
s.new_workbook();
let times: Vec<u32> = (1u32..).filter(|t| !matches!(t % 10, 3 | 6)).take(800).collect();
let mut rows = vec![json!(["t", "y"])];
rows.extend(times.iter().map(|t| json!([t, (t % 7) as f64 + f64::from(*t) * 0.5])));
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let start = times[400];
let err = s.execute("data.forecastSheet", json!({"range": "A1:B801", "forecastStart": start, "periods": 601})).expect_err("past the cap");
assert!(err.to_string().contains("from 1 to 600"), "{err}");
}
#[test]
fn history_and_periods_are_capped() {
use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods};
+62 -1
View File
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Format Cells…",
["Home", "Cells", "Format"],
Some("Cmd+1"),
"{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
"{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)",
has_doc,
format_cells
),
@@ -870,6 +870,67 @@ mod tests {
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
}
#[test]
fn arabic_vowel_and_direction_marks_add_no_width() {
let plain = approx_text_width("محمد يدرس في المدرسة", 11.0, false);
assert_eq!(approx_text_width("مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", 11.0, false), plain);
assert_eq!(approx_text_width("\u{200F}محمد يدرس في المدرسة\u{200C}", 11.0, false), plain);
assert_eq!(approx_text_width("ب\u{0897}\u{0898}\u{089F}", 11.0, false), approx_text_width("ب", 11.0, false));
assert!(approx_text_width("\u{08E2}", 11.0, false) > 0.0, "the disputed end of ayah is a visible sign");
}
#[test]
fn arabic_text_fits_a_column_about_as_wide_as_latin_text_of_the_same_length() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", "Hello there in school"]]})).unwrap();
s.execute("home.autofitColumnWidth", json!({"cols": "A:B"})).unwrap();
let doc = s.doc().unwrap();
let width = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size).unwrap();
let ratio = width(0) / width(1);
assert!((0.75..=1.15).contains(&ratio), "Arabic {} vs Latin {}", width(0), width(1));
}
#[test]
fn autofit_of_many_separate_columns_fits_only_the_selected_ones() {
let mut s = crate::Session::new();
s.new_workbook();
let long = "a much longer text than the others";
// Columns A, C, E, G, I, K are selected (six runs), the ones between hold long text too.
let row: Vec<_> = (0..11).map(|c| if c % 2 == 0 { json!("x") } else { json!(long) }).collect();
s.execute("range.setValues", json!({"range": "A1", "values": [row]})).unwrap();
s.execute("home.autofitColumnWidth", json!({"cols": "A:A,C:C,E:E,G:G,I:I,K:K"})).unwrap();
let doc = s.doc().unwrap();
let size = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size);
assert!([0, 2, 4, 6, 8, 10].iter().all(|&c| size(c).is_some()));
assert!([1, 3, 5, 7, 9].iter().all(|&c| size(c).is_none()));
}
#[test]
fn arabic_word_ligatures_are_wider_than_letters() {
let em = |t: &str| approx_text_width(t, 72.0, false) / 96.0;
assert!(em("\u{FDFD}") > 8.0);
assert!(em("\u{FDFA}") > 1.0);
let honorifics = ['\u{FD40}', '\u{FD4B}', '\u{FD4C}', '\u{FD4F}', '\u{FDC8}', '\u{FDCF}', '\u{FDFE}', '\u{FDFF}'];
for honorific in honorifics.into_iter().chain(['\u{FBC3}', '\u{FBD2}', '\u{FD90}', '\u{FD91}']) {
assert!(em(&honorific.to_string()) > 1.0, "U+{:04X}", u32::from(honorific));
}
assert!(em("ب") < 0.6);
}
#[test]
fn arabic_digits_are_as_wide_as_ascii_digits_and_punctuation_as_a_letter() {
let width = |t: &str| approx_text_width(t, 11.0, false);
assert_eq!(width("١٢٣"), width("123"));
assert_eq!(width("۴۵۶"), width("456"));
assert_eq!(width("،؟"), width("بب"));
}
#[test]
fn cjk_text_keeps_a_full_em_per_character() {
assert_eq!(approx_text_width("中文", 12.0, false), 2.0 * 12.0 * 96.0 / 72.0);
}
#[test]
fn width_units() {
assert_eq!(chars_to_points(8.43), 64.0);
+86 -13
View File
@@ -9,6 +9,17 @@ use super::{
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
/// sheet, so another sheet needs explicit lines.
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let (sheet, lines) = resolve_lines(s, p, rows)?;
// Sizes and visibility are formatting of the line, which a protected sheet must allow.
if rows {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?;
} else {
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?;
}
Ok((sheet, lines))
}
fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
let key = if rows { "rows" } else { "cols" };
let mut sheet = target_sheet(s, p)?;
if let Some(t) = str_param(p, key) {
@@ -125,12 +136,55 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
c if c.is_ascii_uppercase() => 0.65,
c if c.is_ascii_digit() => 0.56,
c if c.is_ascii() => 0.52,
_ => 1.0,
c if is_zero_width(c) => 0.0,
c => arabic_em(c).unwrap_or(1.0),
};
}
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
}
/// Arabic vowel and Quranic marks, which sit on the previous letter, and invisible format
/// characters such as direction marks and zero-width joiners.
fn is_zero_width(c: char) -> bool {
matches!(
c,
'\u{0610}'..='\u{061A}'
| '\u{061C}'
| '\u{064B}'..='\u{065F}'
| '\u{0670}'
| '\u{06D6}'..='\u{06DC}'
| '\u{06DF}'..='\u{06E4}'
| '\u{06E7}'..='\u{06E8}'
| '\u{06EA}'..='\u{06ED}'
| '\u{0897}'..='\u{089F}'
| '\u{08CA}'..='\u{08E1}'
| '\u{08E3}'..='\u{08FF}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2069}'
| '\u{FE20}'..='\u{FE2F}'
| '\u{FEFF}'
)
}
/// Width in ems of an Arabic-script character, measured from shaped Noto Sans Arabic, Noto Naskh
/// Arabic and DejaVu Sans: joined letters average about half an em, word ligatures such as ﷺ and the
/// rial sign about 1.2 em, the honorific ligatures (U+FD40–FD4B) about 1.6 em, and the bismillah
/// ligature about 11 em. The later honorific ligatures (U+FBC3–FBD2, U+FD4C–FD4F, U+FD90–FD91,
/// U+FDC8–FDCF) are not measured and get the 1.6 em of their siblings rather than the letter width.
/// Arabic-Indic digits are as wide as ASCII digits (0.53–0.57 em in Noto Sans Arabic, Arial, Tahoma
/// and Times New Roman); Arabic punctuation (0.2–0.5 em in the same fonts) keeps the letter width.
fn arabic_em(c: char) -> Option<f32> {
match c {
'\u{FDFD}' => Some(11.0),
'\u{FBC3}'..='\u{FBD2}' | '\u{FD40}'..='\u{FD4F}' | '\u{FD90}'..='\u{FD91}' | '\u{FDC8}'..='\u{FDCF}' => Some(1.6),
'\u{FDF0}'..='\u{FDFC}' | '\u{FDFE}'..='\u{FDFF}' => Some(1.2),
'\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}' => Some(0.56),
'\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{0870}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}' => Some(0.48),
_ => None,
}
}
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
/// quick measure so a huge column doesn't lay out every value.
const EXACT_TEXT_WIDTHS: usize = 2_000;
@@ -140,16 +194,35 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
let measure = s.text_width.clone();
edit(s, |cx| {
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
let mut widths = Vec::new();
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
let mut distinct = 0usize;
let used = sh.used_range();
for (a, b) in lines {
for c in a..=b.min(a + 16383) {
let Some(u) = used else { continue };
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
let mut max: f32 = 0.0;
for (cell, _) in sh.cells.iter_range(col) {
// Selected columns, then one pass over the populated cells (the store is row-oriented, so a
// scan per column would revisit every row for each selected column, empty or not).
let cols: std::collections::BTreeSet<u32> = lines.iter().flat_map(|&(a, b)| a..=b.min(a + 16383)).collect();
let mut maxes: std::collections::BTreeMap<u32, f32> = Default::default();
if let Some(u) = sh.used_range() {
// One pass per run of adjacent selected columns, so columns between distant selections
// (A and XFD) are never visited.
let mut runs: Vec<(u32, u32)> = Vec::new();
for &c in &cols {
match runs.last_mut() {
Some((_, end)) if *end + 1 == c => *end = c,
_ => runs.push((c, c)),
}
}
// Every run is a pass over all the stored rows: many runs (alternating columns) are one
// pass over their whole span instead, the unselected columns skipped.
if runs.len() > 4
&& let (Some(&(first, _)), Some(&(_, last))) = (runs.first(), runs.last())
{
runs = vec![(first, last)];
}
for (first, last) in runs {
let span = RangeRef::new(CellRef::new(u.start.row, first), CellRef::new(u.end.row, last));
for (cell, _) in sh.cells.iter_range(span) {
if !cols.contains(&cell.col) {
continue;
}
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
continue;
}
@@ -172,14 +245,14 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
}
None => approx_text_width(line, st.font.size, st.font.bold),
};
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
let w = w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell);
let max = maxes.entry(cell.col).or_insert(0.0);
*max = max.max(w);
}
}
if max > 0.0 {
widths.push((c, max.clamp(8.0, 1785.0)));
}
}
}
let widths: Vec<(u32, f32)> = maxes.into_iter().filter(|&(_, m)| m > 0.0).map(|(c, m)| (c, m.clamp(8.0, 1785.0))).collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths {
let e = sh.cols.entry(c).or_default();
+20 -7
View File
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Calculation Options",
["Formulas", "Calculation"],
None,
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001), precisionAsDisplayed?: bool (Set precision as displayed: numbers are stored as their format shows them; turning it on rounds every number constant permanently)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
has_doc,
calc_options
),
@@ -179,10 +179,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
let mut top = at.row - 1;
while top > 0
&& (numeric(CellRef::new(top - 1, at.col))
|| sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col)))
{
while top > 0 && numeric(CellRef::new(top - 1, at.col)) {
top -= 1;
}
if numeric(CellRef::new(at.row - 1, at.col)) {
@@ -954,14 +951,30 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(v) = f64_param(p, "maxChange") {
cx.wb.calc.set_max_change(v);
}
let precision = bool_param(p, "precisionAsDisplayed");
if let Some(v) = precision {
cx.wb.calc.precision_as_displayed = v;
if v && !before.precision_as_displayed {
crate::cmd::round_all_constants_as_displayed(&mut cx.wb);
}
}
let c = &cx.wb.calc;
// Switching to automatic, or changing how circular references are calculated outside
// manual mode, recalculates the workbook.
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
// Switching precision as displayed recalculates the workbook, in manual mode too, so the
// values change as soon as it is switched on or off.
let precision_changed = c.precision_as_displayed != before.precision_as_displayed;
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) || precision_changed {
cx.structural = true;
}
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
Ok(json!({
"mode": format!("{:?}", c.mode),
"iterative": c.iterative,
"maxIterations": c.max_iterations,
"maxChange": c.max_change,
"precisionAsDisplayed": c.precision_as_displayed,
}))
})
}
+29 -9
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Properties",
["Chart Design"],
None,
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
has_doc,
chart_set
),
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
cmd!(
"object.move",
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
has_doc,
move_object
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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",
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error",
has_doc,
open_link
),
@@ -850,6 +850,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
@@ -926,6 +928,12 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(v) = u32_param(p, "style") {
c.style = v.clamp(1, 16);
}
if let Some(v) = u32_param(p, "holeSize") {
c.hole_size = v.clamp(10, 90) as u8;
}
if let Some(v) = u32_param(p, "firstSliceAngle") {
c.first_slice_angle = (v % 360) as u16;
}
if let Some(t) = p.get("xTitle") {
c.x_title = t.as_str().map(str::to_string);
}
@@ -1100,6 +1108,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
let d = s.doc()?;
// Sheet protection locks drawing objects, as for a text box's text.
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
return Err(EngineError::Other(PROTECTED.into()));
}
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let scale = (640.0 / w.max(1) as f32).min(1.0);
@@ -1231,13 +1243,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
/// objects are errors, so a stale id never succeeds silently.
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
let kind =
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let o = ObjRef::new(kind, id);
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
return Err(bad(cmd, "no such object"));
}
// A locked form control on a protected sheet stays as it is.
super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
Ok((sheet, o))
}
@@ -1336,9 +1351,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// 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.
/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]).
/// An `http`/`https`/`mailto` address is refused too unless the host set
/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so
/// the user opens such links by clicking and confirming, and the UI then calls
/// [`Session::open_confirmed_link`] (#2181). Opens no dialog.
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(),
@@ -1354,6 +1371,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
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) => {
if s.link_policy != crate::links::LinkPolicy::Allow {
return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into()));
}
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
Ok(json!({"opened": u}))
}
+18 -4
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
has_doc,
run
),
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
"{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
has_doc,
check
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
"{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
@@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
let from = std::mem::replace(m, renamed).name;
// Buttons that run the macro follow the new name.
let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
for si in 0..cx.wb.sheets.len() {
if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
continue;
}
for c in cx.sheet_mut(si)?.controls.iter_mut() {
if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action
&& name.eq_ignore_ascii_case(&from)
{
*name = to.clone();
}
}
}
Ok(())
})?;
Ok(json!({"name": to}))
+2
View File
@@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
data_labels: false,
data_table: None,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
hole_size: 75,
first_slice_angle: 0,
style: 1,
x_title: None,
y_title: None,
+4 -3
View File
@@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec<Json> {
}
pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) {
return Err(other("The workbook is protected and cannot be changed."));
}
let source = source_param(s, p)?;
let active = s.doc()?.wb.active_sheet();
// Validate the source now (header names, size).
@@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?;
Some((si, at))
};
// Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet.
if target.is_none() && s.doc()?.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let replace = bool_param(p, "replace").unwrap_or(false);
let rows = field_list(p.get("rows"));
let columns = field_list(p.get("columns"));
+48
View File
@@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() {
assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err());
}
/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the
/// report still filters case-insensitively.
#[test]
fn a_huge_filter_selection_is_matched_through_a_set() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
let mut selected: Vec<String> = (0..99_999).map(|i| format!("Region {i}")).collect();
selected.push("EAST".into());
s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0));
}
#[test]
fn show_values_as() {
let mut s = sales();
@@ -196,6 +209,41 @@ fn show_values_as() {
assert_eq!(text(&s, "B3"), "Biggest sale");
}
/// Running Total over nested rows adds up each child's value across the top-level items, in the
/// order they appear: the same value as summing the plain values of the earlier items' rows.
#[test]
fn running_total_over_nested_rows_adds_up_across_the_top_level_items() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap();
let r = pt(&s).last_range.unwrap();
let rows: Vec<u32> = (r.start.row + 1..r.end.row).collect();
let read = |s: &Session| -> Vec<(String, f64)> {
rows.iter().map(|row| (text(s, &CellRef::new(*row, 0).a1()), val(s, &CellRef::new(*row, 1).a1()).as_f64().unwrap_or(0.0))).collect()
};
let before = read(&s);
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap();
let after = read(&s);
// Region rows carry the report's top-level items (not indented); the others are products.
let indent = |s: &Session, row: u32| {
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
d.wb.styles.get(sh.style_id(CellRef::new(row, 0))).align.indent
};
let (mut regions, mut products) = (0.0, std::collections::HashMap::<String, f64>::new());
for (i, row) in rows.iter().enumerate() {
let name = &before[i].0;
let total = if indent(&s, *row) == 0 {
regions += before[i].1;
regions
} else {
let t = products.entry(name.clone()).or_insert(0.0);
*t += before[i].1;
*t
};
assert!((after[i].1 - total).abs() < 1e-9, "row {name}: {} vs {total}", after[i].1);
}
}
#[test]
fn compact_vs_tabular_shapes() {
let mut s = sales();
+64 -3
View File
@@ -32,8 +32,69 @@ use header_footer::draw_header_footer;
pub use header_footer::{
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
};
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
type DecodedPicture = (Vec<u8>, u32, u32);
/// What one PDF render shares across the pages an object spans.
#[derive(Default)]
struct PicCache {
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
decoded: std::collections::HashMap<(usize, usize), Option<DecodedPicture>>,
/// Laid-out charts (with their [`chart_cost`]) keyed by (sheet, chart), rendered once however
/// many pages they touch. Only charts that can still be drawn are kept.
charts: std::collections::HashMap<(usize, usize), (Vec<gridcraft_chart::Prim>, usize)>,
/// Charts too big for what was left of the budget: never drawn, so not rendered again.
rejected_charts: std::collections::HashSet<(usize, usize)>,
/// Bytes of picture pixels embedded so far, and chart cost units emitted so far. Each page an
/// object touches gets its own copy in the PDF, so a workbook-controlled anchor over thousands
/// of pages would multiply them without bound.
image_bytes: usize,
chart_prims: usize,
}
impl PicCache {
/// The chart's primitives for one more page, or `None` once they no longer fit
/// [`MAX_CHART_PRIMS`]. `render` runs at most once per chart; a chart that doesn't fit is
/// dropped at once and remembered, so its vectors are never retained.
fn chart(&mut self, key: (usize, usize), render: impl FnOnce() -> Vec<gridcraft_chart::Prim>) -> Option<&[gridcraft_chart::Prim]> {
if self.rejected_charts.contains(&key) {
return None;
}
let (prims, cost) = match self.charts.remove(&key) {
Some(cached) => cached,
None => {
let prims = render();
let cost = chart_cost(&prims);
(prims, cost)
}
};
if self.chart_prims.saturating_add(cost) > MAX_CHART_PRIMS {
self.rejected_charts.insert(key);
return None;
}
self.chart_prims += cost;
Some(&self.charts.entry(key).or_insert((prims, cost)).0)
}
}
/// What a chart's drawing costs in the PDF: one unit per primitive plus one per path point (a
/// smoothed line holds thousands of points, each written out) and per point of a wedge's outline.
fn chart_cost(prims: &[gridcraft_chart::Prim]) -> usize {
use gridcraft_chart::Prim;
prims
.iter()
.map(|p| match p {
Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_add(1),
// At most 400 steps along each of the outer and inner arcs (see `draw_prims`).
Prim::Wedge { .. } => 803,
_ => 1,
})
.fold(0, usize::saturating_add)
}
/// Most picture pixel bytes (RGBA, before PDF packing) one render embeds across all pages.
const MAX_EMBEDDED_IMAGE_BYTES: usize = 512 * 1024 * 1024;
/// Most chart cost units (primitives and path points, see [`chart_cost`]) one render draws across all pages.
const MAX_CHART_PRIMS: usize = 4_000_000;
#[cfg(test)]
use layout::bands;
@@ -102,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics = PicCache::new();
let mut pics = PicCache::default();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
+128 -9
View File
@@ -228,6 +228,82 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end:
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
}
/// Merged areas active at one row of a downward sweep, kept disjoint: by first column, their last
/// column and last row. Adding, dropping and looking up a merge costs log(active merges).
#[derive(Default)]
struct MergeSweep {
cols: std::collections::BTreeMap<u32, (u32, u32)>,
/// (last row, first column) of the active merges, soonest ending first.
ends: std::collections::BinaryHeap<std::cmp::Reverse<(u32, u32)>>,
}
impl MergeSweep {
/// Moves down to `row` (rows never decrease), dropping the merges that end above it.
fn advance(&mut self, row: u32) {
while let Some(std::cmp::Reverse((end, col))) = self.ends.peek().copied() {
if end >= row {
break;
}
self.ends.pop();
self.cols.remove(&col);
}
}
/// Does an active merge meet columns `c0..=c1`? The intervals are disjoint, so only the last
/// one starting at or before `c1` can.
fn meets(&self, c0: u32, c1: u32) -> bool {
self.cols.range(..=c1).next_back().is_some_and(|(_, (end, _))| *end >= c0)
}
/// Adds merge `m`, at row `row`, unless it overlaps an active one.
fn add(&mut self, m: RangeRef, row: u32) -> bool {
self.advance(row);
if self.meets(m.start.col, m.end.col) {
return false;
}
self.cols.insert(m.start.col, (m.end.col, m.end.row));
self.ends.push(std::cmp::Reverse((m.end.row, m.start.col)));
true
}
}
/// The merged areas that meet `block`, ordered by the row where they enter it, then by column,
/// leaving out any that overlaps one kept before it (only a damaged file has overlapping merges;
/// Excel drops them when it repairs one). One sweep down the rows instead of a scan of every
/// merge per cell.
fn block_merges(sh: &Sheet, block: RangeRef) -> Vec<RangeRef> {
let top = |m: &RangeRef| m.start.row.max(block.start.row);
let mut merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
merges.sort_by_key(|m| (top(m), m.start.col));
let mut sweep = MergeSweep::default();
merges.retain(|m| sweep.add(*m, top(m)));
merges
}
/// Answers "is this cell inside a merge?" for cells asked in row-major order, from the merges of
/// [`block_merges`].
struct Covered<'a> {
merges: &'a [RangeRef],
top: u32,
next: usize,
sweep: MergeSweep,
}
impl<'a> Covered<'a> {
fn new(merges: &'a [RangeRef], block: RangeRef) -> Self {
Self { merges, top: block.start.row, next: 0, sweep: MergeSweep::default() }
}
fn covers(&mut self, c: CellRef) -> bool {
while let Some(m) = self.merges.get(self.next).filter(|m| m.start.row.max(self.top) <= c.row) {
self.sweep.add(*m, m.start.row.max(self.top));
self.next += 1;
}
self.sweep.advance(c.row);
self.sweep.meets(c.col, c.col)
}
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>) {
let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0));
@@ -237,17 +313,18 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
let block = b.range();
let sh = b.sh;
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
let covered = |c: CellRef| merges.iter().any(|m| m.contains(c));
// Cells worth visiting.
let merges = block_merges(sh, block);
let by_start: std::collections::HashMap<CellRef, RangeRef> = merges.iter().map(|m| (m.start, *m)).collect();
// Cells worth visiting, in row-major order (which `Covered` needs).
let mut covered = Covered::new(&merges, block);
let cells: Vec<CellRef> = if block.count() <= DENSE_BLOCK {
block.iter().filter(|c| !covered(*c)).collect()
block.iter().filter(|c| !covered.covers(*c)).collect()
} else {
let mut set: Vec<CellRef> = sh.cells.iter_range(block).map(|(c, _)| c).collect();
set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c)));
set.sort_unstable();
set.dedup();
set.retain(|c| !covered(*c));
set.retain(|c| !covered.covers(*c));
set
};
pg.save();
@@ -277,14 +354,14 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
.filter(|(c, l)| {
l.fill.is_some()
|| l.cf.is_some()
|| merges.iter().any(|m| m.start == *c)
|| by_start.contains_key(c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
match merges.iter().find(|m| m.start == c) {
match by_start.get(&c) {
Some(m) => {
let (x, y, w, h) = b.rect(*m);
(x, y, w, h, true)
@@ -322,7 +399,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
continue;
}
let bd = &l.style.borders;
let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) {
let (right, bottom) = match by_start.get(c).filter(|_| merged) {
Some(m) => (
b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right,
b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom,
@@ -407,11 +484,12 @@ pub(super) struct PrintedBlock {
pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
let by_start: std::collections::HashMap<CellRef, RangeRef> = block_merges(b.sh, range).into_iter().map(|m| (m.start, m)).collect();
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let cell_range = by_start.get(cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
@@ -503,3 +581,44 @@ pub(super) fn make_block<'a>(
}
Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale }
}
#[cfg(test)]
mod merge_tests {
use super::{CellRef, Covered, RangeRef, Sheet, block_merges};
fn r(a: &str) -> RangeRef {
RangeRef::parse(a).unwrap()
}
#[test]
fn block_merges_drop_overlaps_and_cover_like_a_scan() {
let mut sh = Sheet::new("S");
sh.merges = vec![r("B2:C3"), r("E1:F8"), r("B2:B5"), r("A10:D10"), r("H3:H4"), r("Z1:Z2"), r("C3:D4"), r("A1:A1048576")];
let block = r("B1:H12");
let merges = block_merges(&sh, block);
// B2:B5 and C3:D4 overlap B2:C3, which comes first; Z1:Z2 is outside the block.
assert_eq!(merges, vec![r("E1:F8"), r("B2:C3"), r("H3:H4"), r("A10:D10")]);
let mut covered = Covered::new(&merges, block);
for c in block.iter() {
assert_eq!(covered.covers(c), merges.iter().any(|m| m.contains(c)), "{}", c.a1());
}
// Cells asked sparsely, still in order.
let mut covered = Covered::new(&merges, block);
for (a, want) in [("C1", false), ("F5", true), ("G5", false), ("D10", true), ("H12", false)] {
assert_eq!(covered.covers(CellRef::parse(a).unwrap()), want, "{a}");
}
}
#[test]
fn many_stacked_merges_are_swept_once() {
// 50,000 merges over the same cells: one is kept, and the block is checked against it
// without comparing every cell with every merge.
let mut sh = Sheet::new("S");
sh.merges = (0..50_000u32).map(|i| RangeRef::new(CellRef::new(0, 0), CellRef::new(199, 1 + i % 50))).collect();
let block = r("A1:AZ200");
let merges = block_merges(&sh, block);
assert_eq!(merges, vec![r("A1:B200")]);
let mut covered = Covered::new(&merges, block);
assert_eq!(block.iter().filter(|c| covered.covers(*c)).count(), 400);
}
}
+18 -7
View File
@@ -153,6 +153,17 @@ pub struct HfSection {
pub size: Option<f32>,
}
/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name
/// (or file path) each time it appears, and a JSON workbook's names have no length limit.
const MAX_SECTION_BYTES: usize = 64 * 1024;
/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out.
fn push_field(text: &mut String, s: &str) {
if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES {
text.push_str(s);
}
}
/// Expands a header/footer string into its left, centre and right sections.
pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
let mut out: [HfSection; 3] = Default::default();
@@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
i += 1 + digits.len();
}
}
sec.text.push_str(&n.to_string());
push_field(&mut sec.text, &n.to_string());
}
'N' | 'n' => sec.text.push_str(&cx.pages.to_string()),
'D' | 'd' => sec.text.push_str(&cx.date),
'T' | 't' => sec.text.push_str(&cx.time),
'A' | 'a' => sec.text.push_str(&cx.sheet),
'F' | 'f' => sec.text.push_str(&cx.file),
'Z' | 'z' => sec.text.push_str(&cx.path),
'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()),
'D' | 'd' => push_field(&mut sec.text, &cx.date),
'T' | 't' => push_field(&mut sec.text, &cx.time),
'A' | 'a' => push_field(&mut sec.text, &cx.sheet),
'F' | 'f' => push_field(&mut sec.text, &cx.file),
'Z' | 'z' => push_field(&mut sec.text, &cx.path),
'&' => sec.text.push('&'),
'B' | 'b' => sec.bold = !sec.bold,
'I' | 'i' => sec.italic = !sec.italic,
+22
View File
@@ -224,6 +224,12 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(r) => trim_full(sh, r),
None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))),
};
// A print area from a file or JSON can name coordinates past the grid; pagination walks every
// line of the area (zero-height rows never fill a band), so it is held to the grid first.
let area = RangeRef::new(
CellRef::new(area.start.row.min(MAX_ROWS - 1), area.start.col.min(MAX_COLS - 1)),
CellRef::new(area.end.row.min(MAX_ROWS - 1), area.end.col.min(MAX_COLS - 1)),
);
let title_rows = title_span(st.title_rows, MAX_ROWS);
let title_cols = title_span(st.title_cols, MAX_COLS);
let digits = format!("{}", area.end.row + 1).len() as f32;
@@ -379,3 +385,19 @@ pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32
pub(super) fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 {
if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_print_area_past_the_grid_is_held_to_the_grid() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.default_row_height = 0.0;
sh.print.print_area = Some(RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) });
let lay = layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap();
assert_eq!((lay.area.end.row, lay.area.end.col), (MAX_ROWS - 1, MAX_COLS - 1));
assert!(lay.pages.len() <= MAX_PAGES);
}
}
+16 -6
View File
@@ -88,9 +88,14 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
let decoded = pics.decoded.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
// Every page the picture touches embeds its own copy: past the budget it is left out.
let bytes = px.len();
if pics.image_bytes.saturating_add(bytes) <= super::MAX_EMBEDDED_IMAGE_BYTES {
pics.image_bytes += bytes;
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
}
}
}
for shp in &sh.shapes {
@@ -120,15 +125,20 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
}
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
for (chi, ch) in sh.charts.iter().enumerate() {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
// As for pictures, each page gets its own copy of the drawing commands.
let prims = pics.chart((si, chi), || {
let data = gridcraft_chart::resolve(wb, si, ch);
gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure)
});
if let Some(prims) = prims {
draw_prims(pg, prims, Xf { ox: x, oy: y, k: b.k });
}
}
pg.restore();
}
+103
View File
@@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() {
assert_eq!(r.text, "P4/9 9:00 AM");
assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain");
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
// A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB.
let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx };
let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long);
assert!(c.text.is_empty(), "the name alone is over the limit");
let short = HfContext { sheet: "S".repeat(1000), ..Default::default() };
assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000);
}
#[test]
@@ -695,6 +701,79 @@ fn bands_unit() {
assert!(w.len() > 2);
}
/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines.
fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
}
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
}
}
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
}
if out.len() > 2000 {
return out;
}
}
out.push(line);
}
out
}
#[test]
fn wrap_lines_matches_the_remeasuring_wrapper() {
let texts = [
"the quick brown fox jumps",
"Supercalifragilistic",
"a b c\n\nverylongwordthatneedsbreaking short\nend ",
"Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM",
"€ ü ß — unicode Ünïcödé text",
"",
" ",
];
for text in texts {
for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] {
for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] {
assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}");
}
}
}
}
#[test]
fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() {
// One character per line: the old wrapper re-measured and copied the rest of the word for
// each of the 100,000 lines before checking the limit.
let word = "w".repeat(100_000);
let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0);
assert!(lines.len() <= 2002, "{}", lines.len());
assert!(lines.iter().all(|l| l == "w"));
// A long paragraph in a very wide box is one line.
let para = vec!["word"; 50_000].join(" ");
assert_eq!(wrap_lines(&para, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]);
}
#[test]
fn center_across_spans_following_empty_center_across_cells() {
let mut styles = StyleTable::default();
@@ -859,3 +938,27 @@ fn equations_print_with_the_symbol_font() {
// A glyph no standard font has is a [?] box.
assert!(contains(&pdf, "(?) Tj"));
}
#[test]
fn chart_budget_counts_path_points_and_drops_charts_that_do_not_fit() {
use gridcraft_chart::Prim;
let line = |n: usize| Prim::Line { pts: vec![[0.0, 0.0]; n], color: [0, 0, 0, 255], width: 1.0, dash: false };
// A smoothed line is its points, not one unit.
assert_eq!(chart_cost(&[line(4000), Prim::Circle { cx: 0.0, cy: 0.0, r: 1.0, fill: None, stroke: None }]), 4002);
// Room for exactly two copies of a 1,000-point chart.
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 2002, ..Default::default() };
let mut renders = 0;
for expect in [true, true, false, false] {
let got = pics.chart((0, 0), || {
renders += 1;
vec![line(1000)]
});
assert_eq!(got.is_some(), expect);
}
assert_eq!(renders, 1, "a rejected chart is not rendered again");
assert!(pics.charts.is_empty(), "and its primitives are not kept");
// A chart too big for what is left is dropped without being cached at all.
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 10, ..Default::default() };
assert!(pics.chart((0, 1), || vec![line(1000)]).is_none());
assert!(pics.charts.is_empty() && pics.rejected_charts.contains(&(0, 1)));
}
+39 -24
View File
@@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet
const PAD_PX: f32 = 2.0;
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
const MAX_WRAP_LINES: usize = 2000;
/// Word-wraps `text` to `width` points.
///
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
/// character is measured once: re-measuring the line for every word, or the rest of an
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
let space = units_of(" ");
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
let mut line_units: u64 = 0;
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
line = cand;
let word_units = units_of(word);
if line.is_empty() {
line.push_str(word);
line_units = word_units;
} else if fits(line_units + space + word_units) {
line.push(' ');
line.push_str(word);
line_units += space + word_units;
} else {
out.push(std::mem::take(&mut line));
line = word.to_string();
line.push_str(word);
line_units = word_units;
}
// Break a single over-long word by characters. The cut that fits is found by binary
// search (each try measures one candidate prefix), so an over-long word costs
// O(n log n) measurements instead of re-measuring the whole cut per character for
// every emitted line (`a` × 100k pasted text would be cubic worst case).
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
let chars: Vec<char> = line.chars().collect();
let (mut lo, mut hi) = (1usize, chars.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
let cut: String = chars[..mid].iter().collect();
if gridcraft_pdf::text_width(font, size, &cut) <= width {
lo = mid;
} else {
hi = mid - 1;
// Break a single over-long word by characters: each piece is the longest that fits
// (at least one character).
if !fits(line_units) && line.chars().nth(1).is_some() {
let mut piece = String::new();
let mut units: u64 = 0;
for c in line.chars() {
let w = u64::from(font.char_width(c));
if !piece.is_empty() && !fits(units + w) {
out.push(std::mem::take(&mut piece));
units = 0;
if out.len() > MAX_WRAP_LINES {
return out;
}
}
piece.push(c);
units += w;
}
if lo == chars.len() {
break;
}
out.push(chars[..lo].iter().collect());
line = chars[lo..].iter().collect();
line = piece;
line_units = units;
}
if out.len() > 2000 {
if out.len() > MAX_WRAP_LINES {
return out;
}
}
+21 -1
View File
@@ -163,6 +163,8 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
edit(s, |cx| {
// FORMULATEXT reads a sheet's protection: its results are recalculated.
cx.recalc_all = true;
cx.sheet_mut(sheet)?.protection = Some(prot.clone());
Ok(Json::Null)
})
@@ -182,7 +184,10 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.protection = None;
// Only removing protection changes what FORMULATEXT reads; a no-op unprotect skips the full recalculation.
if cx.sheet_mut(sheet)?.protection.take().is_some() {
cx.recalc_all = true;
}
Ok(Json::Null)
})
}
@@ -404,6 +409,21 @@ mod tests {
assert_eq!(at(&s), (0, "A1".to_string()));
}
#[test]
fn protecting_and_unprotecting_leave_table_headers_alone() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"], [1, 2]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B2", "header": true})).unwrap();
// An imported header can hold a number: only a structural edit would turn it back into text.
let d = s.doc_mut().unwrap();
std::sync::Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap().set_value(gridcraft_core::CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("review.unprotectSheet", json!({})).unwrap();
let v = s.doc().unwrap().wb.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0));
assert_eq!(v, gridcraft_core::Value::Number(7.0));
}
#[test]
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
let mut s = book();

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