diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eba6ece89..e09499812 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 92451aa83..1cd1422c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4" dependencies = [ "chrono", "dunce", @@ -999,7 +1057,7 @@ dependencies = [ [[package]] name = "craft-web-api" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9" dependencies = [ "js-sys", "serde_json", @@ -1010,7 +1068,7 @@ dependencies = [ [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9" dependencies = [ "wasm-bindgen", "web-sys", @@ -1019,7 +1077,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4" dependencies = [ "egui", "objc2 0.6.5", @@ -1035,6 +1093,38 @@ 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" @@ -1115,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" @@ -1127,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", @@ -1192,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]] @@ -1883,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]] @@ -2023,7 +2126,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.19.1" +version = "0.20.0" dependencies = [ "arboard", "chrono", @@ -2051,7 +2154,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2063,7 +2166,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -2074,7 +2177,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -2087,7 +2190,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.19.1" +version = "0.20.0" dependencies = [ "chrono", "js-sys", @@ -2098,8 +2201,9 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.19.1" +version = "0.20.0" dependencies = [ + "craft-csv", "gridcraft-calc", "gridcraft-chart", "gridcraft-core", @@ -2121,7 +2225,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-core", "serde", @@ -2130,7 +2234,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-core", "regex", @@ -2138,7 +2242,7 @@ dependencies = [ [[package]] name = "gridcraft-math" -version = "0.19.1" +version = "0.20.0" dependencies = [ "ratex-font", "ratex-layout", @@ -2148,7 +2252,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-engine", "log", @@ -2157,7 +2261,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2170,18 +2274,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.19.1" +version = "0.20.0" [[package]] name = "gridcraft-sandbox" -version = "0.19.1" +version = "0.20.0" dependencies = [ "landlock", "libc", @@ -2192,7 +2296,7 @@ dependencies = [ [[package]] name = "gridcraft-ui-egui" -version = "0.19.1" +version = "0.20.0" dependencies = [ "craft-ui", "craft-update", @@ -2219,7 +2323,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.19.1" +version = "0.20.0" dependencies = [ "craft-web-api", "craft-web-host", @@ -2238,16 +2342,19 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.19.1" +version = "0.20.0" 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", @@ -2352,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" @@ -2992,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" @@ -4804,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", @@ -4878,7 +5004,7 @@ dependencies = [ "chrono", "cmpv2", "cms", - "const-oid", + "const-oid 0.9.6", "der", "hex", "rand 0.10.3", @@ -4917,7 +5043,7 @@ dependencies = [ "base64 0.22.1", "chrono", "cms", - "const-oid", + "const-oid 0.9.6", "hex", "pem", "rustls-pki-types", @@ -6642,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", @@ -6710,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.19.1" +version = "0.20.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f244db53f..05608a0ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.19.1" +version = "0.20.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -26,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 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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", 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" @@ -44,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 @@ -72,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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", 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" diff --git a/ROADMAP.md b/ROADMAP.md index cb4065a2c..fd27ee9eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -180,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 | XLSX import warns when a pivot cache is missing, malformed or too large, naming the cache and saying its PivotTable was kept as plain cells, instead of dropping the PivotTable silently (#1039) | | 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 | @@ -198,9 +199,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) | | 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | | 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | +| 2026-10-13 | Grid: resizing several selected columns or rows previews on the dragged one and applies the size to all on release; Esc cancels (#1082) | | 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 | PDF writer: objects are written straight to the output, identical pictures are stored once, style operators are emitted only when they change, `q` nesting is limited to 28 and polyline joins no longer leak into later strokes (#816) | +| 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) | | 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 | Performance: a chart is resolved and rendered once per change of its workbook, zoom or fonts instead of on every frame; idle frames, hovering and scrolling reuse it (#1139, part of #1098) | | 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) | @@ -301,6 +306,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 | trivial | Progress log: pivot cache import warnings (#1039) | | 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 | @@ -313,6 +319,9 @@ 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-13 | trivial | Progress log: charts cached between frames (#1139) | +| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) | +| 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) | | 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 | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 5fc37b14c..cfd583baf 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -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, + /// 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, } @@ -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) -> 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 diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs index 57baa2198..d85cb3a3a 100644 --- a/apps/gridcraft-cli/src/check/tests.rs +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -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 = 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() } diff --git a/apps/gridcraft-cli/tests/it/cli.rs b/apps/gridcraft-cli/tests/it/cli.rs index dde018ca8..e59295bc4 100644 --- a/apps/gridcraft-cli/tests/it/cli.rs +++ b/apps/gridcraft-cli/tests/it/cli.rs @@ -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). diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 09c6d7e2a..2eb51df3c 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -18,8 +18,8 @@ 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 = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +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 } diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 8704a6e39..72f5d0855 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -35,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 = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index f05b421df..f5232b17c 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -340,12 +340,16 @@ fn main() -> eframe::Result<()> { for f in &files { app.open_path(f); } + // Linux/BSD: Excel's layout — no window-manager title bar above ours; our title bar carries the + // window controls. `GRIDCRAFT_SYSTEM_TITLEBAR=1` keeps the system frame. + app.window_controls = cfg!(not(any(target_os = "macos", target_os = "windows"))) && std::env::var_os("GRIDCRAFT_SYSTEM_TITLEBAR").is_none(); let mut viewport = egui::ViewportBuilder::default() .with_title("GridCraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([640.0, 420.0]) .with_app_id("ai.storyteller.gridcraft") - .with_drag_and_drop(true); + .with_drag_and_drop(true) + .with_decorations(!app.window_controls); if cfg!(target_os = "macos") { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs index 0151ea2f9..2e7083457 100644 --- a/crates/calc/src/eval/special.rs +++ b/crates/calc/src/eval/special.rs @@ -27,62 +27,153 @@ pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { RangeRef::new(sum.start, CellRef::new(row, col)) } +/// Most expression nodes [`precedents`] visits before giving up and marking the formula dynamic. +const MAX_PRECEDENT_NODES: usize = 1_000_000; + /// Collects the static references of an expression with their sheets resolved; `dynamic` is /// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. +/// +/// Every node is visited once (#2183: range-operator chains used to re-walk shared operands, +/// costing 2^k for k operands), with an explicit stack, so a flat chain as long as Excel allows +/// (`1+1+…`, about 4,000 levels deep) never recurses. Past [`MAX_PRECEDENT_NODES`] the result +/// is conservative: `dynamic` is set so the formula recalculates every time. pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { - let mut out = Vec::new(); - let mut dynamic = false; - let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } + let mut c = Precedents { wb, sheet, out: Vec::new(), dynamic: false, boxes: Vec::new() }; + c.collect(e); + (c.out, c.dynamic) +} + +/// Grows the per-sheet bounding box in `boxes` to cover `area`. +fn union_box(boxes: &mut Vec, area: Area) { + match boxes.iter_mut().find(|x| x.sheet == area.sheet) { + Some(x) => x.range = x.range.union(&area.range), + None => boxes.push(area), + } +} + +/// One entry of [`Precedents::collect`]'s explicit stack. `boxes` indexes +/// [`Precedents::boxes`]: the bounding boxes of the innermost enclosing range operator, if any. +enum PrecedentStep<'e> { + /// Visit an expression and queue its children. + Visit(&'e Expr, Option), + /// Both operands of a range operator have been visited: record the bounding boxes they + /// collected in `inner`, growing the `outer` operator's boxes too. + Close { inner: usize, outer: Option }, +} + +struct Precedents<'w> { + wb: &'w Workbook, + sheet: usize, + out: Vec, + dynamic: bool, + /// Per-sheet bounding boxes, one list per range operator (`A1:INDEX(…)`) met so far. + boxes: Vec>, +} + +impl Precedents<'_> { + /// Records `area`, also growing the bounding boxes of the enclosing range operator, if any. + fn add(&mut self, area: Area, boxes: Option) { + if let Some(b) = boxes.and_then(|i| self.boxes.get_mut(i)) { + union_box(b, area); } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); + self.out.push(area); + } + + fn push(&mut self, r: &Reference, range: RangeRef, boxes: Option) { + match &r.sheet { + SheetSel::Current => self.add(Area { sheet: self.sheet, range }, boxes), + SheetSel::Named(n) => { + if let Some(i) = self.wb.sheet_index(n) { + self.add(Area { sheet: i, range }, boxes); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (self.wb.sheet_index(a), self.wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + self.add(Area { sheet: s, range }, boxes); + } } } } - }; - e.walk(&mut |x| match x { - Expr::Ref(r) => push(&mut out, r, r.range()), - // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when - // the criteria range's size isn't known statically, recalculate the formula every time. - Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { - (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { - let (crit, sum) = (c.range(), s.range()); - push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); - } - (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} - _ => dynamic = true, - }, - Expr::Call(n, _) - if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => - { - dynamic = true - } - Expr::Name(_) | Expr::Struct(_) => dynamic = true, - Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, - // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between - // the references inside it, not only those references. - Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { - let mut inner = precedents(wb, sheet, a).0; - inner.extend(precedents(wb, sheet, b).0); - let mut boxes: Vec = Vec::new(); - for area in inner { - match boxes.iter_mut().find(|x| x.sheet == area.sheet) { - Some(x) => x.range = x.range.union(&area.range), - None => boxes.push(area), + } + + /// Visits every node of `root` once, parents before children, left to right. Areas found + /// inside a range operator (`A1:INDEX(…)`) also grow that operator's per-sheet bounding boxes. + fn collect(&mut self, root: &Expr) { + let mut pending = vec![PrecedentStep::Visit(root, None)]; + let mut nodes = 0usize; + while let Some(step) = pending.pop() { + let (x, boxes) = match step { + PrecedentStep::Visit(x, boxes) => (x, boxes), + PrecedentStep::Close { inner, outer } => { + let areas = self.boxes.get_mut(inner).map(std::mem::take).unwrap_or_default(); + for area in areas { + self.add(area, outer); + } + continue; } + }; + nodes = nodes.saturating_add(1); + if nodes > MAX_PRECEDENT_NODES { + self.dynamic = true; + return; } - out.extend(boxes); + self.visit(x, boxes, &mut pending); } - _ => {} - }); - (out, dynamic) + } + + /// Handles one node for [`Precedents::collect`], queueing the children still to visit. + fn visit<'e>(&mut self, x: &'e Expr, boxes: Option, pending: &mut Vec>) { + match x { + Expr::Ref(r) => self.push(r, r.range(), boxes), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + self.push(s, resized_sum_range(sum, crit.height(), crit.width()), boxes); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => self.dynamic = true, + }, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + self.dynamic = true + } + Expr::Name(_) | Expr::Struct(_) => self.dynamic = true, + Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => self.dynamic = true, + // A range ending in a function's result (`A1:INDEX(B1:B9,n)`), or a chain of plain + // references the parser can't fold into one (`A1:B2:C3`), covers the cells between the + // references inside it, not only those references. Both operands are visited once, + // collecting their bounding boxes on the way, which `Close` then records. + Expr::Binary(BinOp::Range, a, b) => { + let inner = self.boxes.len(); + self.boxes.push(Vec::new()); + pending.push(PrecedentStep::Close { inner, outer: boxes }); + pending.push(PrecedentStep::Visit(b, Some(inner))); + pending.push(PrecedentStep::Visit(a, Some(inner))); + return; + } + _ => {} + } + // Children are pushed in reverse so they pop left to right. + let visit = |e: &'e Expr| PrecedentStep::Visit(e, boxes); + match x { + Expr::Array(rows) => pending.extend(rows.iter().flatten().rev().map(visit)), + Expr::Unary(_, e) | Expr::Paren(e) => pending.push(visit(e)), + Expr::Binary(_, a, b) => { + pending.push(visit(b)); + pending.push(visit(a)); + } + Expr::Call(_, args) => pending.extend(args.iter().rev().map(visit)), + Expr::Invoke(c, args) => { + pending.extend(args.iter().rev().map(visit)); + pending.push(visit(c)); + } + _ => {} + } + } } fn is_special(n: &str) -> bool { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 7f6e753de..5ab61a8b8 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -2331,6 +2331,85 @@ fn median_large_values_recalculate_dependents() { } } +/// #2183: `INDEX(…):INDEX(…):…` used to re-walk each operand of the left-associative range chain, +/// costing 2^k for k operands (28 took 40 s; 60 would take millennia), on open and on every edit. +#[test] +fn range_operator_chain_precedents_are_linear() { + let operands: Vec = (1..=60).map(|i| format!("INDEX(A{i},1)")).collect(); + let src = format!("=SUM({})", operands.join(":")); + let e = gridcraft_formula::parse(&src).unwrap(); + let wb = Workbook::new(); + // With the old 2^k walk this call would never return; #2183 asks for under 100 ms. + #[allow(clippy::disallowed_methods)] // a native test: the clock is there + let t0 = std::time::Instant::now(); + let (areas, dynamic) = crate::eval::precedents(&wb, 0, &e); + let took = t0.elapsed(); + assert!(took < std::time::Duration::from_millis(100), "precedents took {took:?}"); + assert!(!dynamic); + // The chain covers A1:A60, not only the 60 cells named in it. + let whole = gridcraft_core::RangeRef::new(c("A1"), c("A60")); + assert!(areas.iter().any(|a| a.sheet == 0 && a.range == whole), "{areas:?}"); + // Each node is visited once: 60 references plus one box per range operator. + assert!(areas.len() < 200, "{} areas", areas.len()); + + // Through the recalculation engine: setting it finishes and editing a cell in the box recalculates. + let mut t = T::new(); + for i in 1..=60 { + t.set(&format!("A{i}"), "1"); + } + t.set("B1", &src); + assert_eq!(t.num("B1"), 60.0); + t.set("A30", "5"); + assert_eq!(t.num("B1"), 64.0); +} + +/// A chained range of plain references (`A1:B2:C3`, which the parser can't fold into one +/// reference) covers their bounding box, as the evaluator expands it: an edit inside the box but +/// outside each operand still recalculates. +#[test] +fn chained_plain_range_precedents_cover_the_bounding_box() { + let e = gridcraft_formula::parse("=SUM(A1:B2:C3)").unwrap(); + let (areas, dynamic) = crate::eval::precedents(&Workbook::new(), 0, &e); + assert!(!dynamic); + let whole = gridcraft_core::RangeRef::new(c("A1"), c("C3")); + assert!(areas.iter().any(|a| a.sheet == 0 && a.range == whole), "{areas:?}"); + + let mut t = T::new(); + t.set("A1", "1"); + t.set("E1", "=SUM(A1:B2:C3)"); + assert_eq!(t.num("E1"), 1.0); + t.set("C1", "4"); + assert_eq!(t.num("E1"), 5.0); +} + +/// `precedents` walks with an explicit stack: an expression nested deeper than any formula +/// within the length limit can build still finds its references, on a small stack. +#[test] +fn precedents_of_deep_nesting_do_not_recurse() { + let (areas, dynamic) = std::thread::Builder::new() + .stack_size(256 * 1024) + .spawn(|| { + let mut e = gridcraft_formula::parse("=A1").unwrap(); + for _ in 0..10_000 { + e = gridcraft_formula::Expr::Paren(Box::new(e)); + } + let found = crate::eval::precedents(&Workbook::new(), 0, &e); + // Dropping the deep tree recurses; leak it so only `precedents` is under test. + std::mem::forget(e); + found + }) + .unwrap() + .join() + .unwrap(); + assert!(!dynamic); + assert_eq!(areas.len(), 1); + // Shallow nesting stays static. + let e = gridcraft_formula::parse("=((((A1))))").unwrap(); + let (areas, dynamic) = crate::eval::precedents(&Workbook::new(), 0, &e); + assert!(!dynamic); + assert_eq!(areas.len(), 1); +} + #[test] fn cell_filename_names_the_workbook_file() { // Excel returns `/[]`, and the classic sheet-name formula splits it on diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 4b68b8d17..f4e2895fe 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; pub use axis::{Scale, format_number, nice_scale}; pub use raster::rasterize; pub use render::{Swatch, legend_entries, render, supports_trendline}; -pub use resolve::{default_palette, resolve, series_color, series_plot_kind}; +pub use resolve::{default_palette, has_volatile_series, resolve, series_color, series_plot_kind}; pub use sparkline::render_sparkline; /// Straight (non-premultiplied) RGBA colour. diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index baa0730ad..e7663e0d9 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -155,6 +155,131 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth: } } +/// Whether a series formula of `ch` (chart of `sheet`) calls a function whose result changes +/// between evaluations (`NOW`, `TODAY`, `RAND`, `RANDBETWEEN`, `RANDARRAY`), so a result resolved +/// earlier is stale. Defined names the formulas use (`=FreshClock`, `Sheet1!Rates`, a LAMBDA name) +/// are followed into their own formulas, the way the evaluator looks them up; each name once per +/// chart, so a cycle ends. Names nested deeper than [`MAX_NAME_DEPTH`], or more than +/// [`MAX_NAMES_FOLLOWED`] distinct names, count as volatile: a chart whose stability isn't +/// established is not cached. +pub fn has_volatile_series(wb: &Workbook, sheet: usize, ch: &Chart) -> bool { + let mut seen = Seen::new(); + ch.series.iter().any(|s| { + [s.name.as_deref(), s.categories.as_deref(), Some(s.values.as_str()), s.bubble_sizes.as_deref()] + .into_iter() + .flatten() + .any(|f| formula_volatile(wb, sheet, f, 0, &mut seen)) + }) +} + +/// Names followed inside names before a formula counts as volatile. +const MAX_NAME_DEPTH: u32 = 8; +/// Distinct defined names followed per chart before the chart counts as volatile. +const MAX_NAMES_FOLLOWED: usize = 64; + +/// Defined names already followed for one chart, each with the sheet its formula is read on. +type Seen<'a> = Vec<(&'a gridcraft_model::DefinedName, usize)>; + +fn formula_volatile<'a>(wb: &'a Workbook, sheet: usize, f: &str, depth: u32, seen: &mut Seen<'a>) -> bool { + if calls_volatile(f) { + return true; + } + name_tokens(f).into_iter().any(|t| { + let Some((n, at)) = defined_name(wb, sheet, t) else { return false }; + // Followed already, or being followed (a cycle): a volatile call in it is found there. + if seen.iter().any(|&(m, s)| std::ptr::eq(m, n) && s == at) { + return false; + } + if depth >= MAX_NAME_DEPTH || seen.len() >= MAX_NAMES_FOLLOWED { + return true; + } + seen.push((n, at)); + formula_volatile(wb, at, &n.formula, depth + 1, seen) + }) +} + +/// Whether formula text `f` calls one of the volatile functions by name. +fn calls_volatile(f: &str) -> bool { + const VOLATILE: [&str; 5] = ["NOW", "TODAY", "RAND", "RANDBETWEEN", "RANDARRAY"]; + // Formulas read from a file may carry the `_xlfn.` prefix of newer functions. + let up = f.to_ascii_uppercase().replace("_XLFN.", ""); + VOLATILE.iter().any(|name| { + up.match_indices(name).any(|(i, _)| { + let before = up[..i].chars().next_back(); + let after = up[i + name.len()..].trim_start(); + before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && after.starts_with('(') + }) + }) +} + +fn is_ident_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '.' || c == '\\' +} + +/// End of the identifier starting at byte `from` of `f`. +fn ident_end(f: &str, from: usize) -> usize { + f[from..].char_indices().find(|&(_, c)| !is_ident_char(c)).map_or(f.len(), |(j, _)| from + j) +} + +/// End (past the closing quote) of the `q`-quoted text opening at byte `from`; a doubled quote +/// is an escaped one. `None` when it is not closed. +fn quoted_end(f: &str, from: usize, q: char) -> Option { + let mut i = from + q.len_utf8(); + loop { + let j = i + f[i..].find(q)?; + if f[j + q.len_utf8()..].starts_with(q) { + i = j + 2 * q.len_utf8(); + } else { + return Some(j + q.len_utf8()); + } + } +} + +/// The words of formula text `f` that may be defined names: `Name`, `Sheet1!Name`, +/// `'Bob''s'!Name`. String literals and numbers are skipped; function names, column letters and +/// the like come along too and simply aren't found as names. +fn name_tokens(f: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(c) = f[i..].chars().next() { + let start = i; + let end = match c { + '"' => { + i = quoted_end(f, i, '"').unwrap_or(f.len()); + continue; + } + '\'' => quoted_end(f, i, '\'').unwrap_or(f.len()), + c if is_ident_char(c) => ident_end(f, i), + c => { + i += c.len_utf8(); + continue; + } + }; + i = end; + if f[end..].starts_with('!') { + let e = ident_end(f, end + 1); + if e > end + 1 { + out.push(&f[start..e]); + } + i = e; + } else if c != '\'' && !c.is_ascii_digit() { + out.push(&f[start..end]); + } + } + out +} + +/// The defined name `t` (`Name` or `Sheet1!Name`) seen from `sheet`, and the sheet its own +/// formula is read on. +fn defined_name<'a>(wb: &'a Workbook, sheet: usize, t: &str) -> Option<(&'a gridcraft_model::DefinedName, usize)> { + // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). + let (at, bare) = match gridcraft_core::split_sheet(t) { + (Some(s), bare) => (wb.sheet_index(&s)?, bare), + (None, bare) => (sheet, bare), + }; + wb.name(bare, at).map(|n| (n, n.scope.unwrap_or(at))) +} + fn to_num(v: &Value) -> Option { match v { Value::Number(n) if n.is_finite() => Some(*n), @@ -305,11 +430,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limi /// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on /// that sheet), the way the evaluator looks names up. fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { - // `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012). - match gridcraft_core::split_sheet(t) { - (Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()), - (None, _) => wb.name(t, sheet).is_some(), - } + defined_name(wb, sheet, t).is_some() } /// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated, diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 8a9b8640e..a6d845387 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -1680,3 +1680,96 @@ fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() { let d = resolve(&wb, 0, &c); assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings")); } + +#[test] +fn volatile_series_formulas_are_detected() { + let wb = Workbook::new(); + let with = |values: &str, name: Option<&str>| { + let mut c = chart(ChartKind::Line); + c.series.push(Series { + name: name.map(Into::into), + categories: None, + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + quartiles: Default::default(), + trendlines: vec![], + }); + c + }; + assert!(has_volatile_series(&wb, 0, &with("=NOW()", None))); + assert!(has_volatile_series(&wb, 0, &with("=SUM(A1:A3)*rand ()", None))); + assert!(has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("=TODAY()")))); + assert!(has_volatile_series(&wb, 0, &with("=RANDARRAY(5)", None))); + assert!(has_volatile_series(&wb, 0, &with("=_xlfn.RANDARRAY(5)", None))); + assert!(!has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("Sheet1!$B$1")))); + assert!(!has_volatile_series(&wb, 0, &with("=MYRAND(A1)", None))); + assert!(!has_volatile_series(&wb, 0, &with("=Sheet1!$NOW", None))); + assert!(!has_volatile_series(&wb, 0, &chart(ChartKind::Line))); +} + +#[test] +fn volatile_defined_names_are_followed() { + let with = |values: &str| { + let mut c = chart(ChartKind::Line); + c.series.push(Series { + name: None, + categories: None, + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + quartiles: Default::default(), + trendlines: vec![], + }); + c + }; + let name = |name: &str, scope: Option, formula: &str| gridcraft_model::DefinedName { + name: name.into(), + scope, + formula: formula.into(), + comment: String::new(), + hidden: false, + }; + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().name = "Bob's".into(); + wb.names.push(name("FreshClock", None, "NOW()")); + wb.names.push(name("Later", None, "FreshClock+1")); + wb.names.push(name("Dice", Some(0), "RANDBETWEEN(1,6)")); + wb.names.push(name("Rates", None, "'Bob''s'!$A$1:$A$3")); + wb.names.push(name("Loop", None, "Loop+1")); + wb.names.push(name("Spin", None, "Spin+NOW()")); + wb.names.push(name("Roll", None, "LAMBDA(n,n*RAND())")); + // A workbook-scoped name, a name used inside another, and a sheet-scoped one, bare or qualified. + assert!(has_volatile_series(&wb, 0, &with("=FreshClock"))); + assert!(has_volatile_series(&wb, 0, &with("=SUM(Later, 1)"))); + assert!(has_volatile_series(&wb, 0, &with("=Dice*2"))); + assert!(has_volatile_series(&wb, 0, &with("='Bob''s'!Dice"))); + assert!(has_volatile_series(&wb, 0, &with("=Roll(3)"))); + // A self-referencing name ends (it evaluates to an error, not a changing value); one that + // also calls NOW is volatile. + assert!(!has_volatile_series(&wb, 0, &with("=Loop"))); + assert!(has_volatile_series(&wb, 0, &with("=Spin"))); + // Many series over the same stable name stay cacheable: each name is followed once. + let mut many = with("=Rates"); + let first = many.series[0].clone(); + many.series.extend(std::iter::repeat_n(first, 100)); + assert!(!has_volatile_series(&wb, 0, &many)); + // Names nested deeper than the limit are not known to be stable. + for i in 0..10 { + wb.names.push(name(&format!("Deep{i}"), None, &format!("Deep{}", i + 1))); + } + assert!(has_volatile_series(&wb, 0, &with("=Deep0"))); + // A name that refers to cells, a name inside a string and a name of another scope are stable. + assert!(!has_volatile_series(&wb, 0, &with("=Rates"))); + assert!(!has_volatile_series(&wb, 0, &with("=\"FreshClock\""))); + // Dice belongs to sheet 0: seen from another sheet it isn't defined. + wb.sheets.push(Arc::new(gridcraft_model::Sheet::new("Other"))); + assert!(!has_volatile_series(&wb, 1, &with("=Dice"))); + assert!(has_volatile_series(&wb, 1, &with("=FreshClock"))); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 498c3f068..dfe32498a 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -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 @@ -321,6 +321,13 @@ pub fn split_areas(reference: &str) -> Vec<&str> { 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"]); diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index a0b4fcad6..4d76ef70e 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -454,8 +454,9 @@ pub fn number_to_text(n: f64) -> String { if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 { return sci_text(n, 15); } - // A value just below 1e15 can round up to 1e15 at 15 significant digits. - if format!("{:.14e}", a).parse::().is_ok_and(|r| r >= 1e15) { + // A value just below 1e15 can round up to 1e15 at 15 significant digits. Only that narrow + // band pays for the exact formatting: it runs for every number literal a formula prints. + if a >= 9.999_999_999_999_9e14 && format!("{:.14e}", a).parse::().is_ok_and(|r| r >= 1e15) { return sci_text(n, 15); } // 15 significant digits, trailing zeros trimmed. @@ -506,6 +507,9 @@ mod tests { assert_eq!(number_to_text(999999999999999.9), "1E+15"); assert_eq!(number_to_text(-999999999999999.9), "-1E+15"); assert_eq!(number_to_text(999999999999999.0), "999999999999999"); + assert_eq!(number_to_text(999999999999999.5), "1E+15"); + assert_eq!(number_to_text(999999999999999.4), "999999999999999"); + assert_eq!(number_to_text(999999999999989.9), "999999999999990"); } #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 341d1b35c..9481b0476 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 0f79495fc..37e9cd082 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -26,6 +26,7 @@ pub mod spelling; pub mod stash; pub mod view; pub mod whatif; +pub mod wordart; use gridcraft_calc::Key; use gridcraft_core::{CellRef, RangeRef}; @@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(extra::specs()); v.extend(macros::specs()); v.extend(draw::specs()); + v.extend(wordart::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); v.extend(pivot::specs()); diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 0906f4893..c87906dee 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { 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!( diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index cda2160bf..274c0879b 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -166,14 +166,15 @@ fn filter_blank(v: &Value) -> bool { /// A table's own filter is left to the table, and a row typed after a blank row does not count. /// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { - return; - } + // The AutoFilter checks come first: they are cheap, and most sheets have none. let Some(af) = sh.autofilter.as_ref() else { return }; let r = af.range; if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { return; } + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { + return; + } let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); if let Some(af) = sh.autofilter.as_mut() { af.range = grown; @@ -184,7 +185,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, f64)> = std::collections::HashMap::new(); diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs index c4ae43ef4..2d0ffc3f9 100644 --- a/crates/engine/src/cmd/data/tools.rs +++ b/crates/engine/src/cmd/data/tools.rs @@ -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 { 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 { 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| { @@ -176,8 +183,11 @@ pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, qu let mut in_q = false; let mut last_delim = false; let mut chars = text.chars().peekable(); + let mut at_start = true; while let Some(c) = chars.next() { - if c == quote { + if c == quote && (in_q || at_start) { + at_start = false; + last_delim = false; if in_q && chars.peek() == Some("e) { cur.push(quote); chars.next(); @@ -192,9 +202,11 @@ pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, qu } out.push(std::mem::take(&mut cur)); last_delim = true; + at_start = true; continue; } last_delim = false; + at_start = false; cur.push(c); } out.push(cur); diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index e02ef06b3..0d86fbc36 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -337,11 +337,17 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result { 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()); @@ -349,5 +355,5 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result { } } } - Ok(json!({"invalid": bad_cells})) + Ok(json!({"invalid": bad_cells, "truncated": truncated})) } diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 3efcdaaf4..94ab15041 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -199,7 +199,11 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result { 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; diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index f171c65a8..e6037c09d 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -63,6 +63,11 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let sel = &mut d.selection; + if extend && !jmp { + // Shift+arrows move the edge the anchor doesn't hold, keeping merged cells whole. + sel.extend_by(sheet, dr, dc); + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } // 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. @@ -77,23 +82,32 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result { jump(sheet, from, dr.signum(), dc.signum()) } else { let mut t = from.offset_clamped(dr, dc); + if !extend && let Some(m) = sheet.merge_at(from) { + // Leaving a merged cell moves from its edge. + if dr > 0 { + t = CellRef::new(m.end.offset_clamped(dr, 0).row, t.col); + } else if dc > 0 { + t = CellRef::new(t.row, m.end.offset_clamped(0, dc).col); + } + } let mut guard = 0; while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { - let n = t.offset_clamped(dr.signum(), dc.signum()); + // Step each hidden axis along its own direction, so a diagonal exit from a merge + // (Tab-run return column) keeps its column while skipping hidden rows. + let mut n = CellRef::new( + if sheet.is_row_hidden(t.row) { t.offset_clamped(dr.signum(), 0).row } else { t.row }, + if sheet.is_col_hidden(t.col) { t.offset_clamped(0, dc.signum()).col } else { t.col }, + ); + if n == t { + // The hidden axis has no direction (a move along the other axis): walk as before. + n = t.offset_clamped(dr.signum(), dc.signum()); + } if n == t { break; } t = n; guard += 1; } - if !extend && let Some(m) = sheet.merge_at(from) { - // Leaving a merged cell moves from its edge. - if dr > 0 { - t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); - } else if dc > 0 { - t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); - } - } t }; if let Some(m) = sheet.merge_at(to) @@ -140,7 +154,29 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { let fwd = bool_param(p, "forward").unwrap_or(true); let by_row = bool_param(p, "byRow").unwrap_or(false); let d = s.doc_mut()?; - d.selection.cycle(fwd, by_row); + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let sel = &d.selection; + if sel.is_one_merged_cell(sheet) { + // A selection that is just one merged cell is one cell, as in Excel: Enter and Tab leave + // it for the next cell instead of walking the cells it covers. + let step = if fwd { 1 } else { -1 }; + let (dr, dc) = if by_row { (0, step) } else { (step, 0) }; + selection_move(s, &json!({"dr": dr, "dc": dc}))?; + return Ok(json!({"active": s.doc()?.selection.active.a1()})); + } + // Inside a larger selection the cells a merged cell covers are skipped: it is visited once, + // at its top-left cell. + let area = sel.current(); + let cells = + (u64::from(area.end.row.saturating_sub(area.start.row)) + 1).saturating_mul(u64::from(area.end.col.saturating_sub(area.start.col)) + 1); + let mut next = sel.clone(); + for _ in 0..cells.min(1 << 20) { + next.cycle(fwd, by_row); + if sheet.merge_at(next.active).is_none_or(|m| m.start == next.active) { + break; + } + } + d.selection = next; Ok(json!({"active": d.selection.active.a1()})) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index aa2638801..90213a5e1 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -891,6 +891,21 @@ mod tests { 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; diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index 8b304c889..5a4ba863a 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -194,16 +194,35 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result { 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> = 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 = lines.iter().flat_map(|&(a, b)| a..=b.min(a + 16383)).collect(); + let mut maxes: std::collections::BTreeMap = 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; } @@ -226,14 +245,14 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result { } 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(); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d832ecddf..4bd564db3 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1222,9 +1222,15 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { let sheet = d.wb.active_sheet(); let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; - if shape.kind != ShapeKind::TextBox { + if !matches!(shape.kind, ShapeKind::TextBox | ShapeKind::WordArt) { return Err(bad("shape.setText", "the shape is not a text box")); } + if shape.kind == ShapeKind::WordArt && text.is_empty() { + return Err(bad("shape.setText", "WordArt text must not be empty")); + } + if shape.kind == ShapeKind::WordArt && text.chars().nth(super::wordart::MAX_CHARS).is_some() { + return Err(bad("shape.setText", format!("`text` is longer than {} characters", super::wordart::MAX_CHARS))); + } // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). if sh.is_protected() { return Err(EngineError::Other(PROTECTED.into())); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b3ce40d58..875ebdc2b 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -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, u32, u32)>>; +type DecodedPicture = (Vec, 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>, + /// 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, 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) -> 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()) }; diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs index 0f484f875..0d86de515 100644 --- a/crates/engine/src/cmd/print/layout.rs +++ b/crates/engine/src/cmd/print/layout.rs @@ -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); + } +} diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index faa855cb9..55d9f8ac8 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -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(); } @@ -203,6 +213,21 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl ShapeTextAlign::Center => x + (w - lw) / 2.0, ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, }; + // WordArt's drop shadow: the line again, offset, in the shadow colour (under the text). + if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) { + let off = (size * 0.06).max(0.5); + pg.text(lx + off, yy + off, size, font, sc, l); + } + // WordArt's text outline, as on screen: the line in the outline colour, offset in eight + // directions by half the outline width, under the filled text. + if let Some((c, ow)) = fm.text_outline + && let Some(oc) = rgb_of(c, wb) + { + let d = (ow * b.scale / 2.0).clamp(0.25, 20.0); + for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] { + pg.text(lx + dx * d, yy + dy * d, size, font, oc, l); + } + } pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 323060470..5ebd73301 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -938,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))); +} diff --git a/crates/engine/src/cmd/whatif/advanced_filter.rs b/crates/engine/src/cmd/whatif/advanced_filter.rs index 52630a3ef..21d518a7b 100644 --- a/crates/engine/src/cmd/whatif/advanced_filter.rs +++ b/crates/engine/src/cmd/whatif/advanced_filter.rs @@ -83,6 +83,9 @@ pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool { } } +/// Most (list row, criteria row) pairs one Advanced Filter tests. +const MAX_FILTER_TESTS: u64 = 50_000_000; + pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result { const CMD: &str = "data.advancedFilter"; let d = s.doc()?; @@ -133,6 +136,10 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result { rows.push(conds); } } + // Each list row is tried against every criteria row, so bound their product too. + if u64::from(list.height()) * rows.len() as u64 > MAX_FILTER_TESTS { + return Err(bad(CMD, "the list and criteria ranges together are too large")); + } let matches_row = |row: u32| -> bool { if rows.is_empty() { return true; diff --git a/crates/engine/src/cmd/whatif/tests.rs b/crates/engine/src/cmd/whatif/tests.rs index ec3f5262d..a0a3eda32 100644 --- a/crates/engine/src/cmd/whatif/tests.rs +++ b/crates/engine/src/cmd/whatif/tests.rs @@ -298,6 +298,15 @@ fn consolidate_by_position_and_label() { assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err()); } +#[test] +fn advanced_filter_refuses_a_list_times_criteria_product_over_the_work_limit() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); + // A sparse million-row list against 9,999 blank criteria rows would be ~10 billion tests. + assert!(s.execute("data.advancedFilter", json!({"range": "A2:A1048576", "criteria": "H1:H10000"})).is_err()); +} + /// A hundred thousand positional rows take one key lookup each; a tall source and a wide one /// whose keys would meet in a table of 160 million cells are refused before it is built. #[test] diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs new file mode 100644 index 000000000..3577591a6 --- /dev/null +++ b/crates/engine/src/cmd/wordart.rs @@ -0,0 +1,241 @@ +//! Insert › Text › WordArt: decorative text as a floating shape (`ShapeKind::WordArt`) built on +//! the text-box machinery (anchor, move/resize/delete, undo, XLSX shape text body, printing). +//! The style palette below is original to GridCraft. + +use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor}; +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, has_doc, ok, + u32_param, +}; + +/// One entry of the style gallery. +pub struct WordArtStyle { + pub name: &'static str, + pub fill: Color, + pub outline: Option<(Color, f32)>, + pub shadow: Option, + pub bold: bool, + pub italic: bool, + /// Font size in points. + pub size: f32, +} + +const fn rgb(v: u32) -> Color { + Color::Rgb(v) +} + +/// GridCraft's own WordArt presets. Theme colours where it makes sense (they follow the theme), +/// fixed colours for the rest. +pub const STYLES: [WordArtStyle; 10] = [ + WordArtStyle { name: "Classic", fill: Color::Theme(4, 0), outline: None, shadow: None, bold: true, italic: false, size: 36.0 }, + WordArtStyle { name: "Midnight", fill: rgb(0x1B2A49), outline: None, shadow: Some(rgb(0x8A94A6)), bold: true, italic: false, size: 40.0 }, + WordArtStyle { name: "Sunrise", fill: rgb(0xF2A33A), outline: Some((rgb(0xB5451B), 1.5)), shadow: None, bold: true, italic: false, size: 40.0 }, + WordArtStyle { + name: "Lagoon", + fill: rgb(0x1F9E9A), + outline: Some((rgb(0x0B4F5C), 1.0)), + shadow: Some(rgb(0x9CC9C7)), + bold: true, + italic: true, + size: 36.0, + }, + WordArtStyle { name: "Ember", fill: rgb(0xD64527), outline: None, shadow: Some(rgb(0x5A1A0E)), bold: true, italic: false, size: 44.0 }, + WordArtStyle { + name: "Mint Outline", + fill: rgb(0xFFFFFF), + outline: Some((rgb(0x2E9B6A), 2.0)), + shadow: None, + bold: true, + italic: false, + size: 40.0, + }, + WordArtStyle { name: "Ink Stamp", fill: rgb(0x2F3A8F), outline: Some((rgb(0x2F3A8F), 0.5)), shadow: None, bold: true, italic: false, size: 32.0 }, + WordArtStyle { name: "Orchid", fill: rgb(0x8E4FB5), outline: None, shadow: Some(rgb(0xD9C2E8)), bold: false, italic: true, size: 40.0 }, + WordArtStyle { + name: "Graphite", + fill: rgb(0x4A4F57), + outline: Some((rgb(0xFFFFFF), 1.0)), + shadow: Some(rgb(0x9AA0A8)), + bold: true, + italic: false, + size: 36.0, + }, + WordArtStyle { + name: "Lime Pop", + fill: rgb(0xA6D61C), + outline: Some((rgb(0x3F5A00), 1.5)), + shadow: Some(rgb(0x6E8B2C)), + bold: true, + italic: true, + size: 40.0, + }, +]; + +/// The text of a WordArt inserted without `text`. +pub const DEFAULT_TEXT: &str = "Your text here"; +/// Longest WordArt text (characters). +pub const MAX_CHARS: usize = 1000; +const MAX_POINTS: f64 = 400.0; +const MAX_SIDE: f64 = 4000.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.wordArt", + "WordArt", + ["Insert", "Text"], + None, + "{style?: preset name or 0-based index (Classic, Midnight, Sunrise, Lagoon, Ember, Mint Outline, Ink Stamp, Orchid, Graphite, Lime Pop), text?, at?, width?, height?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}", + has_doc, + insert_word_art + ), + cmd!( + "wordArt.set", + "Format WordArt", + [], + None, + "{id: WordArt id, style?, text?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}", + has_doc, + set_word_art + ), + ] +} + +fn style_param(command: &str, p: &Json) -> Result> { + match p.get("style") { + None | Some(Json::Null) => Ok(None), + Some(Json::Number(n)) => n + .as_u64() + .and_then(|i| STYLES.get(usize::try_from(i).ok()?)) + .map(Some) + .ok_or_else(|| bad(command, format!("`style` index must be 0..{}", STYLES.len() - 1))), + Some(Json::String(name)) => STYLES + .iter() + .find(|s| s.name.eq_ignore_ascii_case(name.trim())) + .map(Some) + .ok_or_else(|| bad(command, format!("unknown WordArt style `{name}`"))), + Some(_) => Err(bad(command, "`style` must be a preset name or index")), + } +} + +fn text_param(command: &str, p: &Json) -> Result> { + match p.get("text") { + None | Some(Json::Null) => Ok(None), + Some(Json::String(t)) if t.is_empty() => Err(bad(command, "`text` must not be empty")), + Some(Json::String(t)) if t.chars().nth(MAX_CHARS).is_some() => Err(bad(command, format!("`text` is longer than {MAX_CHARS} characters"))), + Some(Json::String(t)) => Ok(Some(t.clone())), + Some(_) => Err(bad(command, "`text` must be a string")), + } +} + +/// Applies a preset, then explicit overrides, to a shape's format and fill. +fn apply(command: &str, shape: &mut Shape, style: Option<&WordArtStyle>, p: &Json) -> Result<()> { + if let Some(st) = style { + shape.format.text_color = Some(st.fill); + shape.format.text_outline = st.outline; + shape.format.text_shadow = st.shadow; + shape.format.bold = st.bold; + shape.format.italic = st.italic; + shape.format.font_size = Some(st.size); + } + let f = &mut shape.format; + if let Some(c) = p.get("fill").filter(|v| !v.is_null()) { + f.text_color = Some(super::format::color_param(Some(c)).ok_or_else(|| bad(command, "`fill` must be a hex colour"))?); + } + match p.get("outline") { + None | Some(Json::Null) => {} + Some(Json::Bool(false)) => f.text_outline = None, + Some(v) => { + let c = super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`outline` must be a hex colour or false"))?; + let w = f64_param(p, "outlineWidth").map_or(f.text_outline.map_or(1.5, |o| o.1), |w| w.clamp(0.25, 20.0) as f32); + f.text_outline = Some((c, w)); + } + } + if let (Some(w), Some((c, _))) = (f64_param(p, "outlineWidth"), f.text_outline) { + f.text_outline = Some((c, w.clamp(0.25, 20.0) as f32)); + } + match p.get("shadow") { + None | Some(Json::Null) => {} + Some(Json::Bool(false)) => f.text_shadow = None, + Some(Json::Bool(true)) => f.text_shadow = Some(f.text_shadow.unwrap_or(rgb(0x808080))), + Some(v) => { + f.text_shadow = Some(super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`shadow` must be a hex colour or a boolean"))?) + } + } + if let Some(b) = bool_param(p, "bold") { + f.bold = b; + } + if let Some(b) = bool_param(p, "italic") { + f.italic = b; + } + if let Some(sz) = f64_param(p, "fontSize") { + f.font_size = Some(sz.clamp(1.0, MAX_POINTS) as f32); + } + Ok(()) +} + +fn insert_word_art(s: &mut Session, p: &Json) -> Result { + let style = style_param("insert.wordArt", p)?.unwrap_or(&STYLES[0]); + let text = text_param("insert.wordArt", p)?.unwrap_or_else(|| DEFAULT_TEXT.to_string()); + let d = s.doc()?; + 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 mut shape = Shape { + id, + kind: ShapeKind::WordArt, + anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width: 0.0, height: 0.0, mode: anchor_mode_param(p).unwrap_or_default() }, + fill: Color::Auto, + line: Color::Auto, + text: text.clone(), + format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() }, + math: Default::default(), + }; + apply("insert.wordArt", &mut shape, Some(style), p)?; + // Default box: the longest line at the font size (about 0.62 em per character) plus padding. + let pt = f64::from(shape.format.font_size.unwrap_or(36.0)); + let longest = text.split('\n').map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let lines = text.split('\n').count() as f64; + let width = f64_param(p, "width").unwrap_or(longest * pt * 0.62 * 96.0 / 72.0 + 24.0).clamp(8.0, MAX_SIDE); + let height = f64_param(p, "height").unwrap_or(lines * pt * 1.35 * 96.0 / 72.0 + 12.0).clamp(8.0, MAX_SIDE); + shape.anchor.width = width as f32; + shape.anchor.height = height as f32; + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.shapes.push(shape); + Ok(json!({"shape": id})) + }) +} + +fn set_word_art(s: &mut Session, p: &Json) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("wordArt.set", "`id` must be a WordArt id"))?; + let style = style_param("wordArt.set", p)?; + let text = text_param("wordArt.set", p)?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.active().ok_or_else(|| bad("wordArt.set", "no such WordArt"))?; + let current = sh.shapes.iter().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?; + if current.kind != ShapeKind::WordArt { + return Err(bad("wordArt.set", "the shape is not WordArt")); + } + if sh.is_protected() { + return Err(EngineError::Other(PROTECTED.into())); + } + let mut updated = current.clone(); + apply("wordArt.set", &mut updated, style, p)?; + if let Some(t) = text { + updated.text = t; + } + if &updated == current { + return ok(); + } + edit(s, |cx| { + let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?; + *shape = updated; + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 894a58764..6dd96b042 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; use gridcraft_core::{CellRef, RangeRef}; -use gridcraft_model::{Sheet, Workbook}; +use gridcraft_model::{LineIndex, Sheet, Workbook}; use crate::{EngineError, Result}; @@ -611,6 +611,24 @@ pub(crate) fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +/// [`esc`], or `None` once the escaped text would pass `limit` bytes (nothing larger is built). +fn esc_within(s: &str, limit: usize) -> Option { + let mut out = String::with_capacity(s.len().min(limit)); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + c => out.push(c), + } + if out.len() > limit { + return None; + } + } + Some(out) +} + fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String { let st = wb.styles.get(sh.style_id(c)); let mut css = String::new(); @@ -686,6 +704,30 @@ fn table_dir(sh: &Sheet) -> &'static str { const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024; const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000; +/// Most cells the merge index holds: one entry per merged cell inside the exported range. +const MERGE_INDEX_MAX_ENTRIES: usize = 1_000_000; + +/// The merge covering each cell of `rect`, as `Sheet::merge_at` would find it (the first in the +/// sheet's list), built once so a table lookup is one probe instead of a scan of every merge. +/// `None` when overlapping merges would cost more than a few visits per cell of `rect`, or merged +/// cells fill more than [`MERGE_INDEX_MAX_ENTRIES`]: a hostile file's merge list can't multiply the +/// work or the memory of a bounded export. +fn merge_index(sh: &Sheet, rect: RangeRef) -> Option> { + let mut map = std::collections::HashMap::new(); + let mut left = rect.count().saturating_mul(4); + for m in &sh.merges { + let Some(part) = m.intersection(&rect) else { continue }; + left = left.checked_sub(part.count())?; + for c in part.iter() { + map.entry(c).or_insert(*m); + } + if map.len() > MERGE_INDEX_MAX_ENTRIES { + return None; + } + } + Some(map) +} + /// An HTML fragment for the same range as `plain_text`, previously produced by /// `display::range_text`. Large copies keep plain text only; never return a partial table. pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option { @@ -694,6 +736,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: & return None; } let sh = wb.sheet(sheet)?; + let merges = merge_index(sh, r)?; let mut out = ClipboardHtml(String::new()); write!(out, "", table_dir(sh)).ok()?; for row in r.start.row..=r.end.row { @@ -703,7 +746,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: & out.write_str("").ok()?; for col in r.start.col..=r.end.col { let c = CellRef::new(row, col); - let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) { + let span = if let Some(merged) = merges.get(&c).and_then(|m| m.intersection(&r)) { let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?; if c != CellRef::new(first_row, merged.start.col) { continue; @@ -766,6 +809,14 @@ const HTML_EXPORT_MAX_ROWS: u32 = 100_001; /// tall, wide one) would otherwise be gigabytes of `` before finishing (#2387). const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000; +/// Most bytes of one web page. Every cell may hold long text (shared across many cells in the +/// file), so the cell count alone doesn't bound the page; past this the export fails instead. +const HTML_EXPORT_MAX_BYTES: usize = 256 * 1024 * 1024; + +fn html_too_big() -> EngineError { + EngineError::Other(format!("the web page would be over {} MiB. Save as .xlsx or CSV instead.", HTML_EXPORT_MAX_BYTES / (1024 * 1024))) +} + /// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's /// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor. pub fn to_html(wb: &Workbook, sheet: usize) -> Result { @@ -808,6 +859,24 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { r.width() ))); } + let merges = merge_index(sh, r) + .ok_or_else(|| EngineError::Other("the sheet's merged cells overlap too much, or cover too many cells, for a web page.".into()))?; + // Visible rows and columns of the table before each line, so a merge's span is counted + // in constant time whatever the file says its size is. + let visible_before = |hidden: &dyn Fn(u32) -> bool, from: u32, to: u32| { + let mut v = Vec::with_capacity((to - from) as usize + 2); + v.push(0u32); + for line in from..=to { + v.push(v.last().copied().unwrap_or(0) + u32::from(!hidden(line))); + } + v + }; + let rows_before = visible_before(&|y| sh.is_row_hidden(y), r.start.row, r.end.row); + let cols_before = visible_before(&|x| sh.is_col_hidden(x), r.start.col, r.end.col); + let visible = |before: &[u32], from: u32, a: u32, b: u32| { + let at = |line: u32| before.get((line - from) as usize).copied().unwrap_or(0); + at(b + 1) - at(a) + }; if fixed { out.push_str(""); for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) { @@ -826,27 +895,31 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { } for col in r.start.col..=r.end.col { let c = CellRef::new(row, col); - if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) { + let merge = merges.get(&c); + if merge.is_some_and(|m| m.start != c) || (fixed && sh.is_col_hidden(col)) { continue; } let text = crate::display::cell_text(wb, sh, c); let dir = CellDir::of(sh, c, &text); let css = html_cell_css(wb, sh, c, dir); - let span = sh - .merges - .iter() - .find(|m| m.start == c) + let span = merge .map(|m| { if fixed { - let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count(); - let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count(); + // Spans count the table's visible lines: the merge is clipped to the table. + let rows = visible(&rows_before, r.start.row, m.start.row, m.end.row.min(r.end.row)); + let cols = visible(&cols_before, r.start.col, m.start.col, m.end.col.min(r.end.col)); format!(" rowspan=\"{rows}\" colspan=\"{cols}\"") } else { format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()) } }) .unwrap_or_default(); - let _ = write!(out, "{}", dir.attr(), esc(&text)); + // The text is escaped only as far as the page still has room for it. + let Some(escaped) = esc_within(&text, HTML_EXPORT_MAX_BYTES.saturating_sub(out.len())) else { return Err(html_too_big()) }; + let _ = write!(out, "{escaped}", dir.attr()); + if out.len() > HTML_EXPORT_MAX_BYTES { + return Err(html_too_big()); + } } out.push_str(""); } @@ -854,36 +927,48 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { out.push_str(""); if fixed { // Sheet units are CSS pixels; the table starts at the used range, not at A1. - let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row))); + // Indexed positions: each anchor would otherwise sum every custom row and column before it. + let ri = LineIndex::new(&sh.rows, sh.default_row_height, gridcraft_core::MAX_ROWS); + let ci = LineIndex::new(&sh.cols, sh.default_col_width, gridcraft_core::MAX_COLS); + let (left, top) = used.map_or((0.0, 0.0), |r| (ci.start(r.start.col), ri.start(r.start.row))); let place = |a: &gridcraft_model::Anchor| { format!( "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px", - sh.col_left(a.cell.col) - left + f64::from(a.dx), - sh.row_top(a.cell.row) - top + f64::from(a.dy), + ci.start(a.cell.col) - left + f64::from(a.dx), + ri.start(a.cell.row) - top + f64::from(a.dy), a.width.max(0.0), a.height.max(0.0) ) }; for im in &sh.images { - let _ = write!( - out, - "\"{}\"", - esc(&im.mime), - base64_encode(&im.data), - esc(&im.alt), - place(&im.anchor) - ); + // The base64 text is a third longer than the picture: refused before it is built. + if out.len().saturating_add(im.data.len().div_ceil(3).saturating_mul(4)) > HTML_EXPORT_MAX_BYTES { + return Err(html_too_big()); + } + // The attributes are escaped only as far as the page has room for them. + let room = HTML_EXPORT_MAX_BYTES.saturating_sub(out.len()); + let (Some(mime), Some(alt)) = (esc_within(&im.mime, room), esc_within(&im.alt, room)) else { return Err(html_too_big()) }; + let _ = write!(out, "\"{alt}\"", base64_encode(&im.data), place(&im.anchor)); + if out.len() > HTML_EXPORT_MAX_BYTES { + return Err(html_too_big()); + } } let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES; for ch in &sh.charts { if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget)? { svg_budget -= svg.len(); out.push_str(&svg); + if out.len() > HTML_EXPORT_MAX_BYTES { + return Err(html_too_big()); + } } } out.push_str(""); } out.push_str(""); + if out.len() > HTML_EXPORT_MAX_BYTES { + return Err(html_too_big()); + } Ok(out) } diff --git a/crates/engine/src/io/sylk.rs b/crates/engine/src/io/sylk.rs index 8eded1042..114574e9f 100644 --- a/crates/engine/src/io/sylk.rs +++ b/crates/engine/src/io/sylk.rs @@ -1,156 +1,57 @@ -//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems -//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon): -//! -//! - `ID` starts the file; `E` ends it. -//! - `C;Y;X;K;E` is a cell. `Y` and `X` may be left out to stay on the -//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a -//! formula in R1C1 notation; `S;R;C` shares the formula of another cell. -//! - `P;P` adds a number format to the file's list; `F;P;Y;X` gives a cell the -//! n-th format (0-based), and `F;W ` sets column widths in characters. -//! -//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with -//! the same relative and absolute references, as Excel does when it opens the file. +//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to +//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed +//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats +//! interned as styles and widths converted from characters. use std::collections::HashMap; use std::sync::Arc; -use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters}; +use craft_csv::sylk; +use gridcraft_core::{CellError, CellRef, Value}; use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook}; use crate::{EngineError, Result}; -/// The largest SYLK file read, as for other text imports: past it the file is refused. -const MAX_BYTES: usize = 512 << 20; -/// Column-width records kept from one file; each can cover every column. -const MAX_WIDTH_RECORDS: usize = 1024; - /// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings. pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { - if bytes.len() > MAX_BYTES { - return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20))); - } - let text = match std::str::from_utf8(bytes) { - Ok(s) => s.to_string(), - // Older exports are in a single-byte code page: keep each byte as its Latin-1 character. - Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), - }; - let text = text.strip_prefix('\u{feff}').unwrap_or(&text); - if !text.starts_with("ID") { - return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record)."))); - } + let file = sylk::read(bytes).map_err(|e| match e { + sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)), + _ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")), + })?; + let mut wb = Workbook::new(); let mut sheet = Sheet::new("Sheet1"); - let mut formats: Vec = Vec::new(); - // Cells' number formats by index into `formats`, applied once every format is known. - let mut cell_formats: Vec<(CellRef, usize)> = Vec::new(); - let mut formulas: HashMap = HashMap::new(); - let mut widths: Vec<(u32, u32, f64)> = Vec::new(); - let (mut row, mut col) = (0u32, 0u32); - let mut skipped = 0usize; - for line in text.lines() { - let fields = split_fields(line); - let Some((kind, rest)) = fields.split_first() else { continue }; - match kind.as_str() { - "C" => { - let (mut value, mut formula, mut shared) = (None, None, None::<(Option, Option)>); - let mut bad = false; - for f in rest { - let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); - match tag { - "Y" => match position(arg, MAX_ROWS) { - Some(r) => row = r, - None => bad = true, - }, - "X" => match position(arg, MAX_COLS) { - Some(c) => col = c, - None => bad = true, - }, - "K" => value = Some(parse_value(arg)), - "E" => formula = Some(arg.to_string()), - "S" => shared = Some((None, None)), - // In a shared-formula cell, R and C name the cell whose formula it shares. - "R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)), - "C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))), - _ => {} - } - } - if bad { - skipped += 1; - continue; - } - let at = CellRef::new(row, col); - if let Some((Some(r), Some(c))) = shared { - formula = formulas.get(&CellRef::new(r, c)).cloned(); - } - let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); - if let Some(v) = value { - cell.value = v; - } - if let Some(r1c1) = formula { - let a1 = r1c1_formula_to_a1(&r1c1, at); - let f = Formula::new(&a1); - if f.expr.is_some() { - cell.formula = Some(Arc::new(f)); - formulas.insert(at, r1c1); - } else { - skipped += 1; - } - } - sheet.set_cell(at, cell); + let mut skipped = file.skipped; + let mut styles = HashMap::new(); + for (&(row, col), c) in &file.cells { + let value = match &c.value { + sylk::Value::Number(n) => Value::Number(*n), + sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())), + sylk::Value::Bool(b) => Value::Bool(*b), + sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error), + _ => Value::Empty, + }; + let mut cell = Cell::value(value); + if let Some(a1) = &c.formula { + let f = Formula::new(a1); + if f.expr.is_some() { + cell.formula = Some(Arc::new(f)); + } else { + skipped += 1; } - "F" => { - let mut format = None; - for f in rest { - let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8)); - match tag { - "Y" => row = position(arg, MAX_ROWS).unwrap_or(row), - "X" => col = position(arg, MAX_COLS).unwrap_or(col), - "P" => format = arg.parse::().ok(), - "W" => { - let mut parts = arg.split_whitespace(); - if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next()) - && let (Some(first), Some(last), Ok(width)) = - (position(first, MAX_COLS), position(last, MAX_COLS), width.parse::()) - && first <= last - && (0.0..=255.0).contains(&width) - // A file of width records can't make the import loop over every column - // again and again. - && widths.len() < MAX_WIDTH_RECORDS - { - widths.push((first, last, width)); - } - } - _ => {} - } - } - if let Some(format) = format { - cell_formats.push((CellRef::new(row, col), format)); - } - } - // Number formats only: `P;F…` and `P;E…` are fonts. - "P" => { - if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) { - formats.push(code.to_string()); - } - } - "E" => break, - _ => {} } + if let Some(code) = &c.format { + cell.style = *styles + .entry(code.as_str()) + .or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() })); + } + sheet.set_cell(CellRef::new(row, col), cell); } - for (first, last, width) in widths { - let size = crate::cmd::format::chars_to_points(width) as f32; - for c in first..=last { + for w in &file.column_widths { + let size = crate::cmd::format::chars_to_points(w.width) as f32; + for c in w.first..=w.last { sheet.cols.entry(c).or_default().size = Some(size); } } - let mut wb = Workbook::new(); - let mut styles = HashMap::new(); - for (at, index) in cell_formats { - let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue }; - let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() })); - let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty)); - cell.style = style; - sheet.set_cell(at, cell); - } if let Some(first) = wb.sheet_mut(0) { sheet.name = std::mem::take(&mut first.name); *first = sheet; @@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { } Ok((wb, warnings)) } - -/// A record's fields: `;` separates them and `;;` is a literal semicolon. -fn split_fields(line: &str) -> Vec { - let mut fields = vec![String::new()]; - let mut chars = line.trim_end_matches('\r').chars().peekable(); - while let Some(c) = chars.next() { - if c == ';' { - if chars.peek() == Some(&';') { - chars.next(); - if let Some(f) = fields.last_mut() { - f.push(';'); - } - } else { - fields.push(String::new()); - } - } else if let Some(f) = fields.last_mut() { - f.push(c); - } - } - fields -} - -/// A 1-based row or column number → 0-based, within `max`. -fn position(arg: &str, max: u32) -> Option { - arg.trim().parse::().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1) -} - -/// A `K` value: `"text"` (everything between the first and the last quote, as written), a -/// boolean, an error or a number. -fn parse_value(arg: &str) -> Value { - if let Some(text) = arg.strip_prefix('"') { - let text = text.strip_suffix('"').unwrap_or(text); - return Value::Text(Arc::from(text)); - } - if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") { - return Value::Bool(arg.eq_ignore_ascii_case("TRUE")); - } - if let Some(e) = CellError::parse(arg) { - return Value::Error(e); - } - match arg.trim().parse::() { - Ok(n) if n.is_finite() => Value::Number(n), - _ => Value::Text(Arc::from(arg)), - } -} - -/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or -/// nothing (the cell's own row or column). -#[derive(Clone, Copy, Debug, PartialEq)] -enum Part { - Absolute(u32), - Relative(i64), -} - -impl Part { - /// The 0-based index and whether it is absolute, or `None` when it falls off the sheet. - fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> { - match self { - Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)), - Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)), - } - } -} - -/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and -/// the index after it, or `None` when the text isn't a reference part. -fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> { - match chars.get(i) { - Some('[') => { - let close = chars[i..].iter().position(|&c| c == ']')? + i; - let inner: String = chars[i + 1..close].iter().collect(); - Some((Part::Relative(inner.parse().ok()?), close + 1)) - } - Some(c) if c.is_ascii_digit() => { - let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i); - let n: String = chars[i..end].iter().collect(); - Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end)) - } - _ => Some((Part::Relative(0), i)), - } -} - -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$') -} - -/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`: -/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and -/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference -/// off the sheet becomes `#REF!`. -pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String { - let chars: Vec = formula.chars().collect(); - let mut out = String::with_capacity(formula.len()); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - if c == '"' || c == '\'' { - // Copy the quoted run as it is; a doubled quote stays inside it. - let mut j = i + 1; - while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) { - j += if chars[j] == c { 2 } else { 1 }; - } - let stop = (j + 1).min(chars.len()); - out.extend(&chars[i..stop]); - i = stop; - continue; - } - let starts_word = i == 0 || !is_word_char(chars[i - 1]); - if starts_word - && matches!(c, 'R' | 'r' | 'C' | 'c') - && let Some((text, next)) = reference_at(&chars, i, at) - { - out.push_str(&text); - i = next; - continue; - } - // Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference. - if is_word_char(c) { - let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len()); - out.extend(&chars[i..end]); - i = end; - } else { - out.push(c); - i += 1; - } - } - out -} - -/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the -/// word there isn't a reference (a function or a name). -fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> { - let mut j = i; - let mut row = None; - let mut col = None; - if matches!(chars.get(j), Some('R' | 'r')) { - let (part, next) = parse_part(chars, j + 1)?; - row = Some(part); - j = next; - } - if matches!(chars.get(j), Some('C' | 'c')) { - let (part, next) = parse_part(chars, j + 1)?; - col = Some(part); - j = next; - } - if row.is_none() && col.is_none() { - return None; - } - // A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names. - if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') { - return None; - } - let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1)); - let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c))); - // One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it. - let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace()); - let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':'); - let text = match (row, col) { - (Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")), - (Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }), - (None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }), - (None, None) => None, - }; - Some((text.unwrap_or_else(|| "#REF!".into()), j)) -} diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index c68c43f48..ad8ca40f2 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -763,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() { ("R[-5]C", "#REF!"), ("\"RC\"&R1C1", "\"RC\"&$A$1"), ] { - assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}"); + assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}"); } } @@ -810,3 +810,74 @@ fn reading_a_fifo_is_refused_without_blocking() { assert!(err.contains("only regular files"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Merges far from what is copied or exported, in a hostile number, are indexed once instead of +/// being scanned for every cell; a merge running past the table is clipped to it. +#[test] +fn html_merge_lookups_and_spans_stay_bounded() { + let mut wb = book_with_picture("A1", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + for i in 0..200_000u32 { + sh.merges.push(RangeRef::new(CellRef::new(500_000 + i * 2, 5), CellRef::new(500_000 + i * 2, 6))); + } + sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(gridcraft_core::MAX_ROWS - 1, 1))); + sh.set_value(CellRef::new(2, 1), gridcraft_core::Value::Number(2.0)); + let html = copied(&wb, "A1:C50"); + assert!(html.contains("rowspan=\"50\" colspan=\"2\""), "{html}"); + let page = to_html(&wb, 0).unwrap(); + // The table is A1:B3 (the used range): the whole-height merge spans its three rows only. + assert!(page.contains("rowspan=\"3\" colspan=\"2\""), "{page}"); + // Merges stacked over one another past a few per cell are refused, not walked. + let sh = wb.sheet_mut(0).unwrap(); + sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(2, 1)); 100]; + assert!(to_html(&wb, 0).is_err()); + assert!(range_html(&wb, 0, RangeRef::parse("A1:B3").unwrap(), "").is_none()); +} + +/// Thousands of pictures over thousands of custom rows: each is placed through a row index. +#[test] +fn html_export_places_many_pictures_over_many_custom_rows() { + let mut wb = book_with_picture("A1", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + for row in 0..50_000 { + sh.rows.insert(row, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); + } + let image = sh.images[0].clone(); + for i in 0..5_000u32 { + let mut im = image.clone(); + im.anchor.cell = CellRef::new(40_000 + i, 0); + sh.images.push(im); + } + let page = to_html(&wb, 0).unwrap(); + assert!(page.contains(&format!("top:{}px", 30 * 44_999)), "last picture placed by the index"); +} + +/// One long text shown by many cells: the page fails at its byte limit instead of growing on. +#[test] +fn html_export_refuses_a_page_past_its_byte_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let text = gridcraft_core::Value::text("x".repeat(32_000)); + for row in 0..10_000 { + sh.set_value(CellRef::new(row, 0), text.clone()); + } + let err = to_html(&wb, 0).unwrap_err(); + assert!(err.to_string().contains("MiB"), "{err}"); +} + +#[test] +fn escaping_stops_at_the_room_left() { + assert_eq!(esc_within("a&b\"d", 100).as_deref(), Some(esc("a&b\"d").as_str())); + assert_eq!(esc_within("&&&", 14), None, "three `&` are 15 bytes"); + assert_eq!(esc_within("&&&", 15).as_deref(), Some("&&&")); +} + +#[test] +fn the_merge_index_holds_a_bounded_number_of_cells() { + let mut sh = Sheet::new("S"); + let rect = RangeRef::new(CellRef::new(0, 0), CellRef::new(1_100_000, 0)); + sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(999_999, 0))); + assert_eq!(merge_index(&sh, rect).map(|m| m.len()), Some(1_000_000)); + sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1_000_000, 0))]; + assert!(merge_index(&sh, rect).is_none()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58979526c..e068f7a1e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -305,6 +305,19 @@ fn trim_to_budget(history: &mut Vec, byte_budget: usize) { } } +/// A writer that only counts the bytes written to it. +struct ByteCounter(usize); + +impl std::io::Write for ByteCounter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(buf.len()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + impl DocState { /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook @@ -638,7 +651,11 @@ impl Session { /// Journals a command, then trims the oldest entries past either cap. A recording in progress /// keeps pointing at the same entry (the steps the trim dropped are lost). fn push_journal(&mut self, id: &str, params: Json) { - let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len()); + // Counted as it is written, without building the text: a paste's params can be MBs. + let size = |e: &(String, Json)| { + let mut counter = ByteCounter(0); + serde_json::to_writer(&mut counter, e).map_or(0, |()| counter.0) + }; // `journal` is public: if it was changed from outside, measure it again. if self.journal_sizes.len() != self.journal.len() { self.journal_sizes = self.journal.iter().map(size).collect(); diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 96c6f504a..1247b702c 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -1281,9 +1281,18 @@ fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { /// Objects added, changed and removed between two versions of a sheet. fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { - let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); - let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); - let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); + // Objects grouped by id, so each lookup is a map probe instead of a scan of the list. + fn group<'a, T>(v: &'a [T], id: &impl Fn(&T) -> u32) -> std::collections::HashMap> { + let mut m: std::collections::HashMap> = std::collections::HashMap::new(); + for x in v { + m.entry(id(x)).or_default().push(x); + } + m + } + let (gb, ga) = (group(b, &id), group(a, &id)); + let added = a.iter().filter(|x| !gb.contains_key(&id(x))).count(); + let removed = b.iter().filter(|x| !ga.contains_key(&id(x))).count(); + let changed = b.iter().filter(|x| ga.get(&id(x)).is_some_and(|ys| ys.iter().any(|y| *y != *x))).count(); (added, changed, removed) } let parts = [ diff --git a/crates/engine/src/pivot_calc.rs b/crates/engine/src/pivot_calc.rs index 014ad2db8..e8b4df271 100644 --- a/crates/engine/src/pivot_calc.rs +++ b/crates/engine/src/pivot_calc.rs @@ -153,6 +153,11 @@ pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option) -> Result< Expr::Name(n) if n.starts_with(PREFIX) => {} Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")), Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()), + // Evaluated once per group and per report, with only the first value kept: Excel does not + // allow functions that need arrays in a calculated field, and these build one from scalars. + Expr::Call(f, _) if matches!(f.as_str(), "SEQUENCE" | "RANDARRAY" | "MAKEARRAY" | "EXPAND" | "MUNIT") => { + problem = Some(format!("A calculated field can't use {f}: it builds an array.")) + } _ => {} } }); @@ -332,6 +337,16 @@ mod tests { assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err()); } + #[test] + fn a_calculated_field_cannot_build_arrays_from_scalars() { + for f in + ["=SEQUENCE(5000,1000)", "=SUM(RANDARRAY(1000,1000))", "=MAKEARRAY(9,9,LAMBDA(r,c,r*c))+Sales", "=expand(Sales,1000,1000)", "=MUNIT(500)"] + { + assert!(compile(f, &fields).unwrap_err().contains("builds an array"), "{f}"); + } + assert!(compile("=SUM({1,2,3})*Sales", &fields).is_ok(), "an array constant is bounded by the formula's own length"); + } + #[test] fn deep_and_cyclic_chains_are_bounded() { // 300 calculated fields, each using the previous one, and a two-field cycle. diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 3f3feee9d..c0c6959cd 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -43,6 +43,10 @@ impl Selection { pub fn is_single_cell(&self) -> bool { self.ranges.len() == 1 && self.current().is_single() } + /// Whether the selection is exactly one merged cell, which Enter and Tab treat as one cell. + pub fn is_one_merged_cell(&self, sheet: &Sheet) -> bool { + self.ranges.len() == 1 && sheet.merge_at(self.active).is_some_and(|m| m == self.current()) + } /// The corner of the current area opposite the anchor: the end that Shift+arrows move. pub fn moving_end(&self) -> CellRef { let r = self.current(); @@ -67,23 +71,28 @@ impl Selection { /// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel. pub fn expand_merges(&mut self, sheet: &Sheet) { for r in self.ranges.iter_mut() { - if r.is_full_cols() || r.is_full_rows() { - continue; - } - let mut cur = *r; - loop { - let mut grown = cur; - for m in &sheet.merges { - if m.intersects(&grown) { - grown = grown.union(m); - } - } - if grown == cur { - break; - } - cur = grown; - } - *r = cur; + *r = with_merges(sheet, *r); + } + } + /// Shift+arrows: moves the edge of the current area that the anchor doesn't hold by `dr` + /// rows and `dc` columns, keeping merged cells whole, as Excel does. The anchor is the whole + /// merged cell it is in, so from a selected merged cell Shift+Left and Shift+Up widen the + /// selection past its left and top edges. A step that would shrink the area onto the inside + /// of a merged cell (where keeping it whole would undo the step) goes on to the next line + /// that does shrink it, and when the area can't shrink any more it grows in that direction. + pub fn extend_by(&mut self, sheet: &Sheet, dr: i64, dc: i64) { + let anchor = sheet.merge_at(self.anchor).unwrap_or(RangeRef::cell(self.anchor)); + let mut r = self.current(); + if dc != 0 { + r = step_edge(sheet, r, anchor, dc, true); + } + if dr != 0 { + r = step_edge(sheet, r, anchor, dr, false); + } + if let Some(last) = self.ranges.last_mut() { + *last = r; + } else { + self.ranges.push(r); } } /// Moves the active cell within the selection (Enter/Tab inside a multi-cell selection). @@ -128,6 +137,91 @@ impl Selection { } } +/// `r` grown until every merged cell it touches is inside it. Whole columns and whole rows stay +/// as they are. +fn with_merges(sheet: &Sheet, r: RangeRef) -> RangeRef { + if r.is_full_cols() || r.is_full_rows() { + return r; + } + let mut cur = r; + loop { + let mut grown = cur; + for m in &sheet.merges { + if m.intersects(&grown) { + grown = grown.union(m); + } + } + if grown == cur { + return cur; + } + cur = grown; + } +} + +/// One Shift+arrow step of [`Selection::extend_by`] along one axis (`cols`: columns, else rows). +fn step_edge(sheet: &Sheet, r: RangeRef, anchor: RangeRef, d: i64, cols: bool) -> RangeRef { + let max = i64::from(if cols { MAX_COLS } else { MAX_ROWS }) - 1; + let hidden = |i: i64| if cols { sheet.is_col_hidden(i as u32) } else { sheet.is_row_hidden(i as u32) }; + // `from` moved by `by`, then on past hidden lines in the same direction (not off the sheet). + let land = |from: i64, by: i64| { + let mut t = from.saturating_add(by).clamp(0, max); + // Bounded like the arrow keys' walk over hidden lines. + for _ in 0..100_000 { + if !hidden(t) { + break; + } + let n = (t + by.signum()).clamp(0, max); + if n == t { + break; + } + t = n; + } + t + }; + let span = |x: &RangeRef| { + if cols { (i64::from(x.start.col), i64::from(x.end.col)) } else { (i64::from(x.start.row), i64::from(x.end.row)) } + }; + let with = |lo: i64, hi: i64| { + let (lo, hi) = (lo.clamp(0, max) as u32, hi.clamp(0, max) as u32); + let (s, e) = if cols { + (CellRef::new(r.start.row, lo), CellRef::new(r.end.row, hi)) + } else { + (CellRef::new(lo, r.start.col), CellRef::new(hi, r.end.col)) + }; + with_merges(sheet, RangeRef::new(s, e)) + }; + let (lo, hi) = span(&r); + let (alo, ahi) = span(&anchor); + // The anchor holds the low edge unless the area reaches past it on that side. + let moving_hi = lo >= alo; + let shrinking = if moving_hi { d < 0 && hi > ahi } else { d > 0 && lo < alo }; + if shrinking { + let mut t = if moving_hi { hi } else { lo }; + let mut by = d; + loop { + t = if moving_hi { land(t, by).max(ahi) } else { land(t, by).min(alo) }; + let cand = if moving_hi { with(lo, t) } else { with(t, hi) }; + if cand != r { + return cand; + } + if t == if moving_hi { ahi } else { alo } { + break; + } + // The step was undone because a merged cell crosses the new edge, and so is every + // edge up to where that merged cell starts (ends, for the low edge): go on from there + // so a tall or wide merged cell costs one step, not one per line it covers. + let across = sheet.merges.iter().filter(|m| m.intersects(&r)).filter_map(|m| { + let (ms, me) = span(m); + if moving_hi { (ms <= t && me > t).then_some(ms) } else { (ms < t && me >= t).then_some(me) } + }); + t = if moving_hi { across.min().map_or(t, |ms| ms.min(t)) } else { across.max().map_or(t, |me| me.max(t)) }; + by = d.signum(); + } + } + // Grow the area on the side the arrow points to. + if d > 0 { with(lo, land(hi, d)) } else { with(land(lo, d), hi) } +} + impl Selection { /// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of /// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor, @@ -287,6 +381,36 @@ mod tests { assert_eq!(current_region(&s, CellRef::new(5, 0)).a1(), "A1:B20000"); } + #[test] + fn shift_arrows_from_a_merged_cell() { + let mut s = Sheet::new("S"); + s.merges.push(RangeRef::parse("B2:C3").unwrap()); + let at = |s: &Sheet, a1: &str| { + let mut sel = Selection::at(CellRef::parse(a1).unwrap()); + sel.expand_merges(s); + sel + }; + for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] { + let mut sel = at(&s, "B2"); + sel.extend_by(&s, dr, dc); + assert_eq!(sel.a1(), want); + } + // Shrinking onto the inside of the merged cell goes on past it. + let mut sel = at(&s, "A1"); + sel.extend_by(&s, 1, 1); + assert_eq!(sel.a1(), "A1:C3"); + sel.extend_by(&s, 0, -1); + assert_eq!(sel.a1(), "A1:A3"); + // A hidden column is stepped over, and the sheet edge stops the area. + s.cols.entry(3).or_default().hidden = true; + let mut sel = at(&s, "B2"); + sel.extend_by(&s, 0, 1); + assert_eq!(sel.a1(), "B2:E3"); + let mut sel = at(&s, "A1"); + sel.extend_by(&s, -1, -1); + assert_eq!(sel.a1(), "A1"); + } + #[test] fn cycling() { let mut sel = Selection::range(RangeRef::parse("A1:B2").unwrap()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6555a1027..f000eb9fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -532,6 +532,118 @@ fn shape_text_edit_survives_xlsx_roundtrip() { assert_eq!(shape.text, text); } +#[test] +fn word_art_insert_respects_sheet_protection() { + let mut s = s(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + assert!(s.execute("insert.wordArt", json!({})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("insert.wordArt", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1); +} + +#[test] +fn word_art_inserts_with_defaults_and_undoes() { + use gridcraft_model::ShapeKind; + let mut s = s(); + // Empty params: default style and text, no dialog. + let r = s.execute("insert.wordArt", json!({})).unwrap(); + assert!(s.take_ui_requests().is_empty()); + let shape = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + assert_eq!(shape.id, r["shape"].as_u64().unwrap() as u32); + assert_eq!(shape.kind, ShapeKind::WordArt); + assert_eq!(shape.text, crate::cmd::wordart::DEFAULT_TEXT); + assert!(shape.anchor.width > 8.0 && shape.format.text_color.is_some()); + // Named style plus overrides; restyle and edit the text through wordArt.set. + s.execute("insert.wordArt", json!({"style": "ember", "text": "Q3", "fontSize": 60, "shadow": false, "outline": "#112233", "outlineWidth": 3})) + .unwrap(); + let ember = s.doc().unwrap().wb.active().unwrap().shapes[1].clone(); + assert_eq!(ember.format.font_size, Some(60.0)); + assert_eq!((ember.format.text_shadow, ember.format.text_outline.map(|o| o.1)), (None, Some(3.0))); + s.execute("wordArt.set", json!({"id": ember.id, "style": 3, "text": "Q4"})).unwrap(); + let styled = s.doc().unwrap().wb.active().unwrap().shapes[1].clone(); + assert_eq!((styled.text.as_str(), styled.format.italic), ("Q4", true)); + s.execute("shape.setText", json!({"id": ember.id, "text": "Q5"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1], ember); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1].text, "Q4"); + // Moves and deletes like any floating shape. + s.execute("object.move", json!({"kind": "shape", "id": ember.id, "at": "D4"})).unwrap(); + s.execute("object.delete", json!({"kind": "shape", "id": ember.id})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1); +} + +#[test] +fn word_art_rejects_hostile_params() { + let mut s = s(); + let id = s.execute("insert.wordArt", json!({})).unwrap()["shape"].clone(); + let rectangle = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone(); + let before = s.doc().unwrap().wb.clone(); + for (cmd, params) in [ + ("insert.wordArt", json!({"style": "No Such Style"})), + ("insert.wordArt", json!({"style": 99})), + ("insert.wordArt", json!({"style": -1})), + ("insert.wordArt", json!({"text": 5})), + ("insert.wordArt", json!({"text": ""})), + ("insert.wordArt", json!({"text": "x".repeat(crate::cmd::wordart::MAX_CHARS + 1)})), + ("insert.wordArt", json!({"fill": "not a colour"})), + ("wordArt.set", json!({})), + ("wordArt.set", json!({"id": 999})), + ("wordArt.set", json!({"id": rectangle})), + ("shape.setText", json!({"id": id, "text": ""})), + ] { + assert!(matches!(s.execute(cmd, params), Err(crate::EngineError::BadParams { .. })), "{cmd}"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + } + // Absurd sizes are clamped, not trusted. + s.execute("insert.wordArt", json!({"fontSize": 1e12, "width": 1e300, "height": -5})).unwrap(); + let big = &s.doc().unwrap().wb.active().unwrap().shapes[2]; + assert!(big.anchor.width <= 4000.0 && big.anchor.height >= 8.0 && big.format.font_size == Some(400.0)); +} + +#[test] +fn word_art_survives_xlsx_roundtrip() { + use gridcraft_model::ShapeKind; + let mut s = s(); + s.execute("insert.wordArt", json!({"style": "Lagoon", "text": "Sales <2026>\nOutlook & more"})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "wordart.xlsx", "base64": saved["base64"]})).unwrap(); + let after = &s.doc().unwrap().wb.active().unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::WordArt); + assert_eq!(after.text, before.text); + assert_eq!(after.format.text_color, before.format.text_color); + assert_eq!(after.format.text_outline, before.format.text_outline); + assert_eq!(after.format.text_shadow, before.format.text_shadow); + assert_eq!((after.format.bold, after.format.italic, after.format.font_size), (true, true, before.format.font_size)); + assert!(after.format.no_line); +} + +/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a +/// white page. +#[test] +fn word_art_outline_prints() { + let mut s = s(); + s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap(); + let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap(); + let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap(); + let text = String::from_utf8_lossy(&data); + let outline = text.matches("0.18 0.608 0.416 rg").count(); + assert!(outline >= 1, "the outline colour (2E9B6A) is drawn"); + assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text"); + assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top"); + // The last copy drawn is the filled text, in white; every outline copy comes before it. + let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()]; + assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last"); + assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8); +} + #[test] fn no_border_clears_only_shared_edges_and_undo_restores_them() { use gridcraft_core::RangeRef; @@ -3612,6 +3724,8 @@ fn xls_fixture() -> Vec { let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); put(26, &3u16.to_le_bytes()); + // The byte order mark [MS-CFB] 2.2 requires; craft-cfb refuses a header without it. + put(28, &0xFFFEu16.to_le_bytes()); put(30, &9u16.to_le_bytes()); put(32, &6u16.to_le_bytes()); put(44, &1u32.to_le_bytes()); @@ -6264,6 +6378,16 @@ mod objects; mod python_cells; mod python_commands; mod python_files; + +#[test] +fn text_to_columns_keeps_a_mid_field_quote() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "'5\" pipe,abc"})).unwrap(); + s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [","]})).unwrap(); + assert_eq!(v(&s, "A1"), Value::text("5\" pipe")); + assert_eq!(v(&s, "B1"), Value::text("abc")); +} + mod seams; #[test] @@ -6480,6 +6604,19 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() { assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); } +#[test] +fn replace_does_not_search_hidden_formula_source() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)*7"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + // Both give the same answer, and it is not an error: a hidden formula's source is not searched. + let hit = s.execute("edit.replace", json!({"what": "SEQUENCE", "with": "X"})).unwrap(); + let miss = s.execute("edit.replace", json!({"what": "NOSUCHTEXT", "with": "X"})).unwrap(); + assert_eq!(hit, miss); + assert_eq!(hit["replaced"], json!(0), "{hit}"); +} + #[test] fn formulatext_does_not_reveal_a_hidden_formula() { let mut s = s(); @@ -6556,6 +6693,19 @@ fn pivot_new_sheet_destination_respects_workbook_structure_protection() { assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err()); } +#[test] +fn circle_invalid_says_when_its_scan_allowance_ended_early() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[-1]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A200000", "values": [[-1]]})).unwrap(); + s.execute("data.validation", json!({"range": "A1:A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + let out = s.execute("data.circleInvalid", json!({})).unwrap(); + assert_eq!((out["invalid"].as_array().map(Vec::len), out["truncated"].as_bool()), (Some(1), Some(false)), "{out}"); + s.execute("data.validation", json!({"range": "A1:A200000", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + let out = s.execute("data.circleInvalid", json!({})).unwrap(); + assert_eq!(out["truncated"].as_bool(), Some(true), "{out}"); +} + #[test] fn history_charges_a_changed_sheets_comment_text() { let mut s = s(); diff --git a/crates/engine/src/tests/python_cells.rs b/crates/engine/src/tests/python_cells.rs index 63cf1d92b..8c7805dc4 100644 --- a/crates/engine/src/tests/python_cells.rs +++ b/crates/engine/src/tests/python_cells.rs @@ -175,8 +175,8 @@ fn text_to_columns_keeps_a_py_piece_as_text() { s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap(); assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7")); s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap(); - // The default text qualifier takes the quotes away, as in Excel. - assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared"); + // A quote in the middle of a field is kept, as in Excel. + assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\")"), "the piece is kept, not cleared"); assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell"); assert_eq!(super::v(&s, "B1"), Value::Number(7.0)); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index ca960d759..d73a70235 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -18,6 +18,7 @@ mod hidden_sheet_navigation; mod ink_roundtrip; mod line_count_overflow; mod long_operator_chains; +mod merged_selection_keys; mod offset_overflow; mod percent_entry; mod pivot_unknown_func; diff --git a/crates/engine/tests/it/merged_selection_keys.rs b/crates/engine/tests/it/merged_selection_keys.rs new file mode 100644 index 000000000..dcb689843 --- /dev/null +++ b/crates/engine/tests/it/merged_selection_keys.rs @@ -0,0 +1,162 @@ +//! Shift+arrows and Enter/Tab on merged cells, as Excel does them. + +use gridcraft_engine::Session; +use serde_json::json; + +/// A new workbook with B2:C3 merged and nothing selected but A1. +fn merged_book() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s +} + +fn shift(s: &mut Session, dr: i64, dc: i64) -> String { + s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true})).unwrap(); + s.doc().unwrap().selection.a1() +} + +fn enter(s: &mut Session, forward: bool, by_row: bool) -> (String, String) { + s.execute("selection.next", json!({"forward": forward, "byRow": by_row})).unwrap(); + let sel = &s.doc().unwrap().selection; + (sel.active.a1(), sel.a1()) +} + +#[test] +fn shift_arrows_from_a_merged_cell_widen_past_each_edge() { + let mut s = merged_book(); + for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] { + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(s.doc().unwrap().selection.a1(), "B2:C3"); + assert_eq!(shift(&mut s, dr, dc), want, "Shift+({dr},{dc}) from the merged cell B2:C3"); + assert_eq!(s.doc().unwrap().selection.active.a1(), "B2", "the active cell stays"); + } +} + +#[test] +fn shift_arrows_keep_going_and_come_back_to_the_merged_cell() { + let mut s = merged_book(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(shift(&mut s, 1, 0), "B2:C4"); + assert_eq!(shift(&mut s, 1, 0), "B2:C5"); + assert_eq!(shift(&mut s, 1, 0), "B2:C6"); + assert_eq!(shift(&mut s, -1, 0), "B2:C5"); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(shift(&mut s, 0, -1), "A2:C3"); + assert_eq!(shift(&mut s, 0, 1), "B2:C3", "back to just the merged cell"); + assert_eq!(shift(&mut s, 0, 1), "B2:D3"); + assert_eq!(shift(&mut s, -1, 0), "B1:D3"); + assert_eq!(shift(&mut s, 1, 0), "B2:D3"); +} + +#[test] +fn shift_arrows_shrink_past_a_merged_cell_they_pulled_in() { + let mut s = merged_book(); + assert_eq!(shift(&mut s, 0, 1), "A1:B1"); + assert_eq!(shift(&mut s, 1, 0), "A1:C3", "B2 pulls in the whole merged cell"); + assert_eq!(shift(&mut s, 0, -1), "A1:A3", "one step back leaves the merged cell out"); + assert_eq!(shift(&mut s, 0, 1), "A1:C3"); + assert_eq!(shift(&mut s, -1, 0), "A1:C1"); + assert_eq!(shift(&mut s, 1, 0), "A1:C3"); + assert_eq!(shift(&mut s, 1, 0), "A1:C4"); +} + +#[test] +fn enter_and_tab_leave_a_selected_merged_cell() { + let mut s = merged_book(); + for (forward, by_row, want) in [(true, false, "B4"), (false, false, "B1"), (true, true, "D2"), (false, true, "A2")] { + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(enter(&mut s, forward, by_row), (want.to_string(), want.to_string()), "forward {forward} byRow {by_row}"); + } +} + +#[test] +fn enter_and_tab_out_of_a_merged_cell_step_over_hidden_lines() { + let mut s = merged_book(); + s.execute("selection.set", json!({"range": "A4"})).unwrap(); + s.execute("home.hideRows", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "D1"})).unwrap(); + s.execute("home.hideColumns", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(enter(&mut s, true, false).0, "B5", "row 4 is hidden"); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + assert_eq!(enter(&mut s, true, true).0, "E2", "column D is hidden"); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + s.execute("selection.move", json!({"dr": 1})).unwrap(); + assert_eq!(s.doc().unwrap().selection.a1(), "B5", "the Down arrow too"); +} + +#[test] +fn enter_inside_a_selection_visits_a_merged_cell_once() { + let mut s = merged_book(); + s.execute("selection.set", json!({"range": "A1:C3", "active": "A1"})).unwrap(); + let seen: Vec = (0..6).map(|_| enter(&mut s, true, false).0).collect(); + assert_eq!(seen, ["A2", "A3", "B1", "B2", "C1", "A1"]); + assert_eq!(s.doc().unwrap().selection.a1(), "A1:C3", "the selection stays"); + let back: Vec = (0..2).map(|_| enter(&mut s, false, false).0).collect(); + assert_eq!(back, ["C1", "B2"]); +} + +#[test] +fn shift_arrow_that_cannot_shrink_the_area_grows_it() { + // A2:B3 merged, anchor B1: Shift+Down pulls the area out to column A, past the anchor. + // Shift+Right can't shrink it from the left (the merged cell would come back), so it grows. + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A2:B3"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + assert_eq!(shift(&mut s, 1, 0), "A1:B3"); + assert_eq!(shift(&mut s, 0, 1), "A1:C3"); + assert_eq!(s.doc().unwrap().selection.active.a1(), "B1"); +} + +#[test] +fn shift_left_shrinks_past_a_chain_of_merged_cells_in_one_step() { + // B2:C3 and D3:E4 are both inside A1:E4. Shift+Left from E can't stop in column D (D3:E4 + // would come back) and stops in C, where B2:C3 is whole. + let mut s = merged_book(); + s.execute("selection.set", json!({"range": "D3:E4"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + assert_eq!(shift(&mut s, 3, 4), "A1:E4"); + assert_eq!(shift(&mut s, 0, -1), "A1:C4"); + assert_eq!(shift(&mut s, -1, 0), "A1:C3", "row 4 holds nothing merged once column D is out"); + assert_eq!(shift(&mut s, 0, -1), "A1:A3", "C can't stop inside B2:C3"); +} + +#[test] +fn shift_up_over_a_tall_merged_cell_takes_one_step() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:C500000"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + assert_eq!(shift(&mut s, 0, 1), "A1:B1"); + assert_eq!(shift(&mut s, 1, 0), "A1:C500000"); + assert_eq!(shift(&mut s, -1, 0), "A1:C1", "row 1 is the last row above the merged cell"); + assert_eq!(shift(&mut s, 1, 0), "A1:C500000"); + assert_eq!(shift(&mut s, 0, -1), "A1:A500000"); +} + +#[test] +fn shift_arrows_change_only_the_last_area() { + let mut s = merged_book(); + s.execute("selection.set", json!({"range": "E5:F6,B2"})).unwrap(); + assert_eq!(s.doc().unwrap().selection.a1(), "E5:F6,B2:C3"); + assert_eq!(shift(&mut s, 0, -1), "E5:F6,A2:C3"); +} + +#[test] +fn enter_skips_a_merged_cell_whose_start_is_outside_the_selection() { + // A whole column isn't widened to the merged cells it touches. B1 is covered by A1:B1, whose + // top-left cell is outside column B, so Enter skips it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1:B1"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "B:B", "active": "B2"})).unwrap(); + assert_eq!(enter(&mut s, false, false).0, "B1048576", "B1 is skipped, going up wraps to the bottom"); +} diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 94756c09f..3c784d2f4 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -215,7 +215,9 @@ impl Expr { /// Visits every node depth-first, parents before children, left to right. pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) { // An explicit stack: a long operator chain must not recurse once per operand. - let mut pending = vec![self]; + // Room for a typical formula up front, so walking one costs one allocation, not several. + let mut pending = Vec::with_capacity(8); + pending.push(self); while let Some(e) = pending.pop() { f(e); match e { @@ -236,6 +238,19 @@ impl Expr { } /// Rewrites every node bottom-up, children left to right before their parent. pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr { + // One operator on a non-chain operand (`A1*2`), the common case, needs no spine: rewrite + // the operands in their boxes. + if let Expr::Binary(op, mut a, mut b) = self { + if !matches!(*a, Expr::Binary(..)) { + *a = std::mem::replace(&mut *a, Expr::Missing).map(f); + *b = std::mem::replace(&mut *b, Expr::Missing).map(f); + return f(Expr::Binary(op, a, b)); + } + return Expr::Binary(op, a, b).map_chain(f); + } + self.map_chain(f) + } + fn map_chain(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr { // Unwind the left spine of a long operator chain into a list, then rebuild it from the // innermost operand outwards, in the order a recursive rewrite would visit the nodes. let mut spine = Vec::new(); @@ -283,6 +298,12 @@ impl Expr { impl Clone for Expr { fn clone(&self) -> Self { + // One operator on a non-chain operand needs no spine. + if let Expr::Binary(op, a, b) = self + && !matches!(**a, Expr::Binary(..)) + { + return Expr::Binary(*op, a.clone(), b.clone()); + } // Copy the left spine of an operator chain in a loop (see [`Expr`]). let mut spine = Vec::new(); let mut node = self; diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 2b6f6d408..f8d73744e 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -69,11 +69,35 @@ fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Resu let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es }; let e = p.expr(0, allow_union)?; match p.peek() { + Tok::Eof if wrapper_height(&e) > MAX_DEPTH => Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }), Tok::Eof => Ok(e), t => Err(p.err(format!("unexpected {}", describe(t)))), } } +/// The most unary operators, parentheses and invocations on one path of `e`. The parser bounds +/// each level's postfix wrappers by its own depth, so levels inside levels can still stack them +/// far past [`MAX_DEPTH`]; code that maps over the tree recurses once per wrapper. +fn wrapper_height(e: &Expr) -> usize { + let mut most = 0; + let mut pending = vec![(e, 0usize)]; + while let Some((e, h)) = pending.pop() { + most = most.max(h); + match e { + Expr::Unary(_, a) | Expr::Paren(a) => pending.push((a, h + 1)), + Expr::Invoke(callee, args) => { + pending.push((callee, h + 1)); + pending.extend(args.iter().map(|a| (a, h + 1))); + } + Expr::Binary(_, a, b) => pending.extend([(&**a, h), (&**b, h)]), + Expr::Call(_, args) => pending.extend(args.iter().map(|a| (a, h))), + Expr::Array(rows) => pending.extend(rows.iter().flatten().map(|a| (a, h))), + _ => {} + } + } + most +} + fn describe(t: &Tok) -> String { match t { Tok::RParen => "`)`".into(), diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 6e3829925..274a66667 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -576,3 +576,19 @@ fn column_starting_with_at_round_trips() { assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}"); } } + +#[test] +fn wrappers_stacked_across_levels_are_bounded_as_a_whole() { + // Each level adds 40 percents inside the parser's per-level bound, so no level is too deep alone. + let stacked = |levels: usize| { + let mut f = "1".to_string(); + for _ in 0..levels { + f = format!("({f}){}", "%".repeat(40)); + } + f + }; + assert!(parse(&stacked(5)).is_ok()); + assert!(parse(&stacked(7)).is_err(), "7 x 41 wrappers on one path"); + assert!(parse(&format!("{}1{}", "(".repeat(200), ")".repeat(200))).is_ok()); + assert!(parse(&format!("={}", vec!["A1%"; 100].join("+"))).is_ok(), "siblings are not stacked"); +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index b569c1617..75b6fbc78 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -13,6 +13,7 @@ mod day_count; mod depreciation; mod securities; mod tvm; +pub(crate) use tvm::cumulative_args; use day_count::{coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd}; use depreciation::{amordegrc, amorlinc, db, ddb, sln, syd, vdb}; diff --git a/crates/functions/src/financial/securities.rs b/crates/functions/src/financial/securities.rs index ea696c8a6..4deb579bd 100644 --- a/crates/functions/src/financial/securities.rs +++ b/crates/functions/src/financial/securities.rs @@ -71,7 +71,8 @@ pub(super) fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; let basis = basis_arg(a, 4)?; - if issue >= settle || rate <= 0.0 || par <= 0.0 { + // Excel accepts equal (whole-day) dates and accrues nothing; only issue after settlement is #NUM!. + if issue > settle || rate <= 0.0 || par <= 0.0 { return Err(CellError::Num); } num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) diff --git a/crates/functions/src/financial/tests.rs b/crates/functions/src/financial/tests.rs index 2788da94a..a12e31022 100644 --- a/crates/functions/src/financial/tests.rs +++ b/crates/functions/src/financial/tests.rs @@ -317,3 +317,28 @@ fn securities_read_dates_in_the_1904_system() { let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); } + +/// Excel truncates ACCRINTM's dates to whole days and accrues nothing when issue equals +/// settlement, in every basis and both date systems; only issue after settlement is #NUM! (#4096). +#[test] +fn accrintm_equal_dates_accrue_zero() { + let day = serial_from_ymd(DateSystem::D1900, 2025, 1, 15).unwrap(); + for basis in 0..=4 { + close(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(1000.0), n(f64::from(basis))]), 0.0); + } + // Distinct times on the same day truncate to equal dates. + close(ev("ACCRINTM", vec![n(day + 0.1), n(day + 0.9), n(0.05), n(1000.0), n(2.0)]), 0.0); + close_tol(ev("ACCRINTM", vec![n(day), n(day + 1.0), n(0.05), n(1000.0), n(2.0)]), 0.1388888888888889, 1e-15); + is_err(ev("ACCRINTM", vec![n(day + 1.0), n(day), n(0.05), n(1000.0), n(2.0)]), CellError::Num); + // Equal dates do not excuse a zero rate or par, or an invalid basis. + is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.0), n(1000.0), n(2.0)]), CellError::Num); + is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(0.0), n(2.0)]), CellError::Num); + is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(1000.0), n(5.0)]), CellError::Num); + + let day4 = serial_from_ymd(DateSystem::D1904, 2025, 1, 15).unwrap(); + for basis in 0..=4 { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + let args = vec![n(day4), n(day4), n(0.05), n(1000.0), n(f64::from(basis))]; + close(crate::call(crate::lookup("ACCRINTM").unwrap(), &args, &mut ctx), 0.0); + } +} diff --git a/crates/functions/src/financial/tvm.rs b/crates/functions/src/financial/tvm.rs index 61710f0bd..872e7cbeb 100644 --- a/crates/functions/src/financial/tvm.rs +++ b/crates/functions/src/financial/tvm.rs @@ -116,7 +116,9 @@ pub(super) fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) } -fn cumulative(a: &[Arg], principal: bool) -> R { +/// The validated (rate, periods, pv, start, end, type) of CUMIPMT/CUMPRINC: every check that +/// refuses a call before its period loop, shared with the lifted-call work preflight. +pub(crate) fn cumulative_args(a: &[Arg]) -> R<(f64, f64, f64, f64, f64, f64)> { let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); let np_i = np.trunc(); @@ -126,6 +128,11 @@ fn cumulative(a: &[Arg], principal: bool) -> R { if end - start > 1e7 || np_i > 1e15 { return Err(CellError::Num); } + Ok((rate, np_i, pv, start, end, typ)) +} + +fn cumulative(a: &[Arg], principal: bool) -> R { + let (rate, np_i, pv, start, end, typ) = cumulative_args(a)?; let p = pmt_calc(rate, np_i, pv, 0.0, typ); let mut sum = 0.0; let mut per = start; diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index d474bf12d..81d9e353e 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -63,26 +63,86 @@ const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XM /// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file. /// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst /// case to a few seconds and still admits 5,000 needles over a 100,000-row table. +/// +/// The same budget covers all the work of a lifted call added up across its elements: criteria +/// aggregates rescanning their ranges, period loops, and array results built per element. const LIFTED_SCAN_BUDGET: usize = 500_000_000; -/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec` -/// is one of the [`SCANS`] lookups and a non-lifted argument is an array. +/// Criteria aggregates: every call tests each cell of its ranges against its criteria, so a call +/// lifted over `n` criteria does `n` x (range cells) work, like the [`SCANS`] lookups. +const CRITERIA: &[&str] = &["COUNTIF", "COUNTIFS", "SUMIF", "SUMIFS", "AVERAGEIF", "AVERAGEIFS", "MAXIFS", "MINIFS"]; + +/// Cumulative TVM functions that step through every period from `start_period` (argument 3) to +/// `end_period` (argument 4) on each call. +const PERIOD_LOOPS: &[&str] = &["CUMIPMT", "CUMPRINC"]; + +/// Budget units one period of a [`PERIOD_LOOPS`] call costs: each period computes a payment and a +/// balance with powers, many times a scanned cell. +const PERIOD_COST: usize = 16; + +/// The work every lifted call of `spec` repeats over its non-lifted arguments: the longest vector +/// a [`SCANS`] lookup searches, or every cell a [`CRITERIA`] aggregate tests. 0 for other +/// functions. fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize { - if !SCANS.contains(&spec.name) { + let scans = SCANS.contains(&spec.name); + if !scans && !CRITERIA.contains(&spec.name) { return 0; } - args.iter() - .enumerate() - .filter(|(i, _)| !spec.is_scalar_arg(*i)) - .filter_map(|(_, a)| match &a.value { - Value::Array(arr) => Some(arr.rows.max(arr.cols)), - _ => None, - }) - .max() - .unwrap_or(0) + // SUMIF and AVERAGEIF read the optional third range only at the coordinates of the first. + let first = args.first().and_then(|a| if let Value::Array(arr) = &a.value { Some(arr.data.len()) } else { None }); + let lens = args.iter().enumerate().filter(|(i, _)| !spec.is_scalar_arg(*i)).filter_map(|(i, a)| match &a.value { + Value::Array(arr) if scans => Some(arr.rows.max(arr.cols)), + Value::Array(arr) if i == 2 && matches!(spec.name, "SUMIF" | "AVERAGEIF") => Some(arr.data.len().min(first.unwrap_or(usize::MAX))), + Value::Array(arr) => Some(arr.data.len()), + _ => None, + }); + if scans { lens.max().unwrap_or(0) } else { lens.fold(0, usize::saturating_add) } +} + +/// The work of one lifted call that depends on its own (lifted) scalar arguments: the number of +/// periods a [`PERIOD_LOOPS`] function steps through. 0 for other functions. +fn call_cost(spec: &FnSpec, cell_args: &[Arg]) -> usize { + if !PERIOD_LOOPS.contains(&spec.name) { + return 0; + } + // Coercing numeric text reads all of it, even in a call the function then refuses, so each + // character is charged as well: of the arguments it coerces in order, up to the first that fails. + let mut parse = 0usize; + for (i, a) in cell_args.iter().enumerate().take(6) { + if let Value::Text(t) = &a.value { + parse = parse.saturating_add(t.len()); + } + if crate::util::num(cell_args, i).is_err() { + break; + } + } + // The function's own validation and coercion (text like "10000000" counts), so a call it + // refuses before its loop costs nothing and no loop goes uncharged. + let periods = crate::financial::cumulative_args(cell_args).map_or(0, |(.., start, end, _)| (end - start + 1.0) as usize); + parse.saturating_add(periods.saturating_mul(PERIOD_COST)) } pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + call_lifted_within(spec, args, ctx, LIFTED_SCAN_BUDGET) +} + +/// Sets the lifted arguments of `cell_args` to their elements at (`r`, `c`). +fn element_args(spec: &FnSpec, args: &[Arg], r: usize, c: usize, cell_args: &mut [Arg]) { + for (i, a) in args.iter().enumerate() { + if spec.is_scalar_arg(i) + && let Value::Array(arr) = &a.value + && let Some(slot) = cell_args.get_mut(i) + { + let v = arr.get_broadcast(r, c); + slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v }; + // A blank element of a written array argument is given, as a scalar blank is. + slot.blank_value = !slot.from_ref && matches!(slot.value, Value::Empty); + } + } +} + +/// [`call_lifted`] with every call's work added up against `budget` (see [`LIFTED_SCAN_BUDGET`]). +fn call_lifted_within(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx, budget: usize) -> Value { let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) }; let args = rejected.as_deref().unwrap_or(args); let mut rows = 0usize; @@ -101,27 +161,42 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val return (spec.imp)(args, ctx); } let needles = rows.saturating_mul(cols); - if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET { + // Every call is charged its repeated scan (at least 1), its own period loop, and the cells of + // an array result (built in full, though only its first cell is kept), so lifting a scalar + // argument can't multiply what one call may do into hours of work: + // `COUNTIF(big_range, SEQUENCE(1e4))`, `CUMIPMT(0.1, 1e7, 1, 1, SEQUENCE(1e4,,1e7,0), 0)`, + // `MUNIT(SEQUENCE(1e4,,3000,0))`. + let per_call = scan_len(spec, args).max(1); + let mut cell_args: Vec = args.to_vec(); + let mut spent = needles.saturating_mul(per_call); + if needles > MAX_CELLS || spent > budget { return Value::Error(CellError::Num); } - let mut cell_args: Vec = args.to_vec(); + // Period loops are known before any call runs, so a call over budget does none of them. + if PERIOD_LOOPS.contains(&spec.name) { + for r in 0..rows { + for c in 0..cols { + element_args(spec, args, r, c, &mut cell_args); + spent = spent.saturating_add(call_cost(spec, &cell_args)); + if spent > budget { + return Value::Error(CellError::Num); + } + } + } + } let mut data = Vec::with_capacity(rows * cols); for r in 0..rows { for c in 0..cols { - for (i, a) in args.iter().enumerate() { - if spec.is_scalar_arg(i) - && let Value::Array(arr) = &a.value - && let Some(slot) = cell_args.get_mut(i) - { - let v = arr.get_broadcast(r, c); - slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v }; - // A blank element of a written array argument is given, as a scalar blank is. - slot.blank_value = !slot.from_ref && matches!(slot.value, Value::Empty); - } - } + element_args(spec, args, r, c, &mut cell_args); let out = (spec.imp)(&cell_args, ctx); data.push(match out { - Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty), + Value::Array(a) => { + spent = spent.saturating_add(a.data.len()); + if spent > budget { + return Value::Error(CellError::Num); + } + a.data.first().cloned().unwrap_or(Value::Empty) + } v => v, }); } @@ -206,6 +281,100 @@ mod tests { assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]); } + /// Security: lifting a criteria aggregate rescans its range for every criterion, so criteria + /// x range cells past the budget is `#NUM!` before any scan runs. + #[test] + fn lifted_criteria_aggregates_stop_past_their_work_budget() { + use gridcraft_core::Array; + let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into()); + is_err(ev("COUNTIF", vec![tall(1_000_000), tall(501)]), CellError::Num); + is_err(ev("SUMIF", vec![tall(1_000_000), tall(501)]), CellError::Num); + is_err(ev("COUNTIFS", vec![tall(1_000_000), tall(501)]), CellError::Num); + // Two ranges of 250,000 cells each count together. + is_err(ev("SUMIFS", vec![tall(250_000), tall(250_000), tall(1001)]), CellError::Num); + // SUMIF reads its sum range only where the criteria range has cells: a one-cell range over a + // million-cell sum range is one cell of work per criterion. + let one = av(Array::filled(1, 1, nv(-1.0)).into()); + assert_eq!(rows_of(&ev("SUMIF", vec![one, tall(501), tall(1_000_000)])).len(), 501); + let counts = ev("COUNTIF", vec![av(col(&[1.0, 2.0, 2.0])), av(col(&[2.0, 1.0, 5.0]))]); + assert_eq!(rows_of(&counts), vec![vec![nv(2.0)], vec![nv(1.0)], vec![nv(0.0)]]); + for name in super::CRITERIA.iter().chain(super::PERIOD_LOOPS) { + assert!(crate::lookup(name).is_some(), "{name} isn't registered"); + } + } + + /// Security: CUMIPMT and CUMPRINC loop over their periods once per lifted element; the + /// periods of all elements are added up first, so an over-budget call runs none of them. + #[test] + fn lifted_period_loops_stop_past_their_work_budget() { + use gridcraft_core::Array; + let ends = |len: usize, end: f64| av(Array::filled(len, 1, nv(end)).into()); + let args = |len: usize, end: f64| vec![n(0.01), n(1e7), n(1000.0), n(1.0), ends(len, end), n(0.0)]; + // 51 elements of 1e7 periods each is past 5e8. + is_err(ev("CUMIPMT", args(51, 1e7)), CellError::Num); + is_err(ev("CUMPRINC", args(51, 1e7)), CellError::Num); + // A period costs more than a scanned cell, so fewer elements are enough. + is_err(ev("CUMIPMT", args(49, 1e7)), CellError::Num); + // Numeric text is coerced by the function, so its periods are charged the same. + let text_ends = av(Array::filled(51, 1, Value::text("10000000")).into()); + is_err(ev("CUMIPMT", vec![n(0.01), n(1e7), n(1000.0), n(1.0), text_ends, n(0.0)]), CellError::Num); + let out = ev("CUMIPMT", args(2, 12.0)); + assert_eq!(rows_of(&out).len(), 2); + assert!(matches!(rows_of(&out).first().and_then(|r| r.first()), Some(Value::Number(x)) if *x < 0.0)); + } + + /// Security: numeric text is parsed once per lifted element even when the call is refused; the + /// characters read are charged, so a long text repeated down a column can't cost gigabytes of parsing. + #[test] + fn lifted_period_loops_charge_the_text_they_parse() { + use gridcraft_core::Array; + // 20,000 elements x 32,000 characters is past 5e8 though no call reaches its loop (rate is negative). + let long = Value::text(format!("{}5", "0".repeat(32_000))); + let ends = av(Array::filled(20_000, 1, long).into()); + is_err(ev("CUMIPMT", vec![n(-0.01), n(10.0), n(1000.0), n(1.0), ends, n(0.0)]), CellError::Num); + // A short text period costs next to nothing. + let ends = av(Array::filled(3, 1, Value::text("12")).into()); + assert_eq!(rows_of(&ev("CUMIPMT", vec![n(0.01), n(20.0), n(1000.0), n(1.0), ends, n(0.0)])).len(), 3); + } + + /// Text after the first argument that fails to coerce is never read, so it is not charged: the + /// elements keep their own errors instead of one budget `#NUM!`. + #[test] + fn lifted_period_loops_charge_only_the_text_they_reach() { + use gridcraft_core::Array; + let rates = av(Array::filled(20_000, 1, Value::text("x")).into()); + let long = Value::text(format!("{}5", "0".repeat(32_000))); + let out = ev("CUMIPMT", vec![rates, n(10.0), n(1000.0), n(1.0), av(long), n(0.0)]); + assert!(rows_of(&out).iter().all(|r| r.first() == Some(&Value::Error(CellError::Value)))); + } + + /// A call the function refuses before its loop (here: end period past the period count) is + /// not charged, so rejected elements can't push a valid one over the budget. + #[test] + fn lifted_period_loops_do_not_charge_rejected_calls() { + use gridcraft_core::Array; + let mut ends = vec![nv(1e7 + 1.0); 60]; + ends.push(nv(12.0)); + let ends = av(Array::new(ends.len(), 1, ends).unwrap_or_else(|| panic!("shape")).into()); + let out = ev("CUMIPMT", vec![n(0.01), n(1e7), n(1000.0), n(1.0), ends, n(0.0)]); + let rows = rows_of(&out); + assert_eq!(rows.len(), 61); + assert!(matches!(rows.last().and_then(|r| r.first()), Some(Value::Number(x)) if *x < 0.0)); + } + + /// Security: an array-producing function lifted over a computed array builds each element's + /// whole result before keeping one cell; their cells count against the budget. + #[test] + fn lifted_array_results_count_against_the_budget() { + use gridcraft_core::Array; + let spec = crate::lookup("MUNIT").unwrap_or_else(|| panic!("MUNIT isn't registered")); + let dims = |n: usize| [av(Array::filled(n, 1, nv(10.0)).into())]; + // Ten 10x10 identity matrices are 1,000 cells. + let out = super::call_lifted_within(spec, &dims(10), &mut TestCtx::default(), 1_010); + assert_eq!(rows_of(&out), vec![vec![nv(1.0)]; 10]); + is_err(super::call_lifted_within(spec, &dims(11), &mut TestCtx::default(), 1_010), CellError::Num); + } + #[test] fn value_params_reject_multi_cell_references() { let s = FnSpec { value_args: &[true, false], ..spec() }; diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 7c84d553b..37c617fc0 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -520,7 +520,7 @@ fn integer_args(a: &[Arg]) -> R> { v.iter() .map(|x| { let t = x.trunc(); - if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) } + if !(0.0..9.007_199_254_740_992e15).contains(x) { Err(CellError::Num) } else { Ok(t as u64) } }) .collect() } diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs index 92449eb26..b6143b4f6 100644 --- a/crates/functions/src/math/tests.rs +++ b/crates/functions/src/math/tests.rs @@ -229,6 +229,20 @@ fn combinatorics() { is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); } +#[test] +fn gcd_and_lcm_reject_negative_fractions_before_truncation() { + for name in ["GCD", "LCM"] { + for x in [-0.1, -0.5, -0.999] { + is_err(ev2(name, x, 2.0), Num); + is_err(ev(name, vec![rf(row(&[2.0, x]))]), Num); + // A zero elsewhere must not hide an invalid negative argument. + is_err(ev2(name, 0.0, x), Num); + } + close(ev2(name, -0.0, 2.0), if name == "GCD" { 2.0 } else { 0.0 }); + close(ev2(name, 12.9, 18.9), if name == "GCD" { 6.0 } else { 36.0 }); + } +} + #[test] fn trig() { close(ev1("SIN", PI / 2.0), 1.0); diff --git a/crates/functions/src/stat/regression.rs b/crates/functions/src/stat/regression.rs index 55bf0aec7..622022ee9 100644 --- a/crates/functions/src/stat/regression.rs +++ b/crates/functions/src/stat/regression.rs @@ -55,12 +55,12 @@ fn strict_num(v: &Value) -> R { } } -/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a -/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated +/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The fit works on a k×k +/// triangle in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated /// gigabytes and could abort the process. pub(super) const MAX_REGRESSORS: usize = 255; -/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. +/// Most multiply-adds (rows × k²) a regression may spend on its factorisation. const MAX_REGRESSION_WORK: usize = 1_000_000_000; /// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, @@ -121,59 +121,136 @@ struct Fit { ssresid: f64, } -/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping -/// (zeroing) collinear variables. Returns the inverse and the dropped flags. -fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { - let p = m.len(); - let mut a: Vec> = m.to_vec(); - let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); - let mut dropped = vec![false; p]; - let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); - for j in 0..p { - let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - let scale = diag.get(j).copied().unwrap_or(0.0); - if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { - if let Some(d) = dropped.get_mut(j) { - *d = true; - } - for row in a.iter_mut() { - if let Some(v) = row.get_mut(j) { - *v = 0.0; - } - } - if let Some(r) = a.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } - if let Some(r) = inv.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } +/// Euclidean norm without intermediate overflow or underflow. +fn norm2(v: &[f64]) -> f64 { + let m = v.iter().fold(0.0f64, |m, x| m.max(x.abs())); + if m == 0.0 || !m.is_finite() { + return m; + } + m * v.iter().map(|x| (x / m) * (x / m)).sum::().sqrt() +} + +/// Result of [`qr_solve`]: the least-squares solution on the kept columns, in the caller's +/// column scale, and what the standard errors need. +struct QrFit { + coef: Vec, + dropped: Vec, + /// Residual sum of squares of the fit. + ssresid: f64, + /// Diagonal of `(X'X)^-1` over the kept columns (0 for dropped ones). + diag: Vec, + /// `xm' (X'X)^-1 xm` over the kept columns. + quad: f64, +} + +/// Least squares by Householder QR on unit-norm columns, instead of the normal equations, whose +/// condition number is the square of the data's. Columns are taken left to right; one whose part +/// outside the span of the kept columns is under 1e-6 of its own length (the 1e-12 relative +/// pivot of the earlier normal-equation solver, on squares) is collinear: it is dropped, with +/// coefficient and standard error 0, as Excel does. +/// +/// `constant[j]` says column `j` was constant before centring. Centring it leaves rounding +/// residue, not 0 (`[0.1; 3]` centres to about -1.4e-17): it is collinear with the intercept, so +/// it is dropped instead of being scaled up to unit length. Whether a column is constant is +/// decided on its raw values, not by comparing the centred length with the offset: a predictor +/// like `[1e13, 1e13 + 1, 1e13 + 2]` is far smaller than its offset and still varies. +fn qr_solve(mut cols: Vec>, mut y: Vec, xm: &[f64], constant: &[bool]) -> QrFit { + let n = y.len(); + let k = cols.len(); + let mut scale = vec![1.0; k]; + let mut dropped = vec![false; k]; + // Householder vectors (unit norm, acting on rows `at..n`) and the columns of R. + let mut refl: Vec<(usize, Vec)> = Vec::new(); + let mut r_cols: Vec> = Vec::new(); + let mut kept: Vec = Vec::new(); + for (j, col) in cols.iter_mut().enumerate() { + let len = norm2(col); + if constant.get(j).copied().unwrap_or(false) || !(len > 1e-300 && len.is_finite()) { + dropped[j] = true; continue; } - let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - for i in 0..p { - if i == j { - continue; - } - let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - if f == 0.0 { - continue; - } - if let Some(r) = a.get_mut(i) { - r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); - } - if let Some(r) = inv.get_mut(i) { - r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); - } + col.iter_mut().for_each(|v| *v /= len); + scale[j] = len; + for (at, v) in &refl { + apply_reflector(*at, v, col); } - if let Some(r) = a.get_mut(j) { - *r = arow; + let at = refl.len(); + let tail = col.get(at..).unwrap_or(&[]); + let alpha = norm2(tail); + if alpha * alpha <= 1e-12 { + dropped[j] = true; + continue; } - if let Some(r) = inv.get_mut(j) { - *r = irow; + let sign = if tail.first().is_some_and(|t| *t < 0.0) { -1.0 } else { 1.0 }; + let mut v: Vec = tail.to_vec(); + if let Some(h) = v.first_mut() { + *h += sign * alpha; + } + let vn = norm2(&v); + v.iter_mut().for_each(|x| *x /= vn); + let mut rc: Vec = col.get(..at).unwrap_or(&[]).to_vec(); + rc.push(-sign * alpha); + r_cols.push(rc); + refl.push((at, v)); + kept.push(j); + } + for (at, v) in &refl { + apply_reflector(*at, v, &mut y); + } + let r = kept.len(); + // Back substitution R c = (Q'y)[..r], then undo the column scaling. + let mut c = vec![0.0; r]; + for i in (0..r).rev() { + let mut t = y.get(i).copied().unwrap_or(0.0); + for jj in i + 1..r { + t -= r_cols.get(jj).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0) * c.get(jj).copied().unwrap_or(0.0); + } + if let Some(ci) = c.get_mut(i) { + *ci = t / r_cols.get(i).and_then(|rc| rc.get(i)).copied().unwrap_or(f64::NAN); } } - (inv, dropped) + let ssresid = y.get(r..n).unwrap_or(&[]).iter().map(|v| v * v).sum::(); + // R^-1 (upper triangular), row i of the inverse at rinv[i]. + let rij = |i: usize, j: usize| r_cols.get(j).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0); + let mut rinv = vec![vec![0.0; r]; r]; + for t in 0..r { + for i in (0..=t).rev() { + let mut v = if i == t { 1.0 } else { 0.0 }; + for jj in i + 1..=t { + v -= rij(i, jj) * rinv.get(jj).and_then(|row| row.get(t)).copied().unwrap_or(0.0); + } + if let Some(cell) = rinv.get_mut(i).and_then(|row| row.get_mut(t)) { + *cell = v / rij(i, i); + } + } + } + let mut coef = vec![0.0; k]; + let mut diag = vec![0.0; k]; + for (i, &j) in kept.iter().enumerate() { + let s = scale.get(j).copied().unwrap_or(1.0); + if let Some(v) = coef.get_mut(j) { + *v = c.get(i).copied().unwrap_or(0.0) / s; + } + let row = rinv.get(i); + if let Some(v) = diag.get_mut(j) { + *v = row.map_or(0.0, |rw| rw.iter().map(|x| x * x).sum::()) / (s * s); + } + } + // xm' (S R'R S)^-1 xm = |R^-T S^-1 xm|². + let w: Vec = kept.iter().map(|&j| xm.get(j).copied().unwrap_or(0.0) / scale.get(j).copied().unwrap_or(1.0)).collect(); + let quad = (0..r) + .map(|t| { + (0..=t).map(|i| rinv.get(i).and_then(|row| row.get(t)).copied().unwrap_or(0.0) * w.get(i).copied().unwrap_or(0.0)).sum::().powi(2) + }) + .sum::(); + QrFit { coef, dropped, ssresid, diag, quad } +} + +/// Applies the Householder reflection `I - 2vv'` on rows `at..` of `x`. +fn apply_reflector(at: usize, v: &[f64], x: &mut [f64]) { + let Some(tail) = x.get_mut(at..) else { return }; + let dot: f64 = v.iter().zip(tail.iter()).map(|(a, b)| a * b).sum(); + tail.iter_mut().zip(v).for_each(|(t, a)| *t -= 2.0 * dot * a); } fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { @@ -189,35 +266,22 @@ fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { } else { (vec![0.0; k], 0.0) }; - // Normal equations on (centred) data. - let mut xtx = vec![vec![0.0; k]; k]; - let mut xty = vec![0.0; k]; - for (row, yv) in x.iter().zip(y) { - let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); - let yd = yv - ym; - for (i, di) in d.iter().enumerate() { - if let Some(t) = xty.get_mut(i) { - *t += di * yd; - } - if let Some(r) = xtx.get_mut(i) { - for (j, dj) in d.iter().enumerate() { - if let Some(v) = r.get_mut(j) { - *v += di * dj; - } - } - } - } - } - let (inv, dropped) = sym_inverse(&xtx); - let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); + // (Centred) predictor columns and response. + let cols: Vec> = + (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect()).collect(); + let yc: Vec = y.iter().map(|v| v - ym).collect(); + let sstot: f64 = if constant { yc.iter().map(|v| v * v).sum() } else { y.iter().map(|v| v * v).sum() }; + // Only centring leaves rounding residue behind; without an intercept a constant column is data. + // Constant means every raw value is the same double: distinct values, however close, vary. + let constant_cols: Vec = (0..k) + .map(|j| { + let mut col = x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)); + let first = col.next(); + constant && first.is_some_and(|f| col.all(|v| v == f)) + }) + .collect(); + let QrFit { coef, dropped, ssresid, diag, quad } = qr_solve(cols, yc, &xm, &constant_cols); let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; - let mut ssresid = 0.0; - let mut sstot = 0.0; - for (row, yv) in x.iter().zip(y) { - let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); - ssresid += (yv - pred).powi(2); - sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; - } let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); let df = nf - used as f64; let ssreg = sstot - ssresid; @@ -230,20 +294,9 @@ fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { _ => Err(CellError::Num), }; let s2 = sey2.unwrap_or(f64::NAN); - let se: Vec = (0..k) - .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) - .collect(); - let se_b = if constant { - let mut q = 0.0; - for (i, mi) in xm.iter().enumerate() { - for (j, mj) in xm.iter().enumerate() { - q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - } - } - Some((s2 * (1.0 / nf + q)).sqrt()) - } else { - None - }; + let se: Vec = + (0..k).map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (diag.get(i).copied().unwrap_or(0.0) * s2).sqrt() }).collect(); + let se_b = if constant { Some((s2 * (1.0 / nf + quad)).sqrt()) } else { None }; Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) } diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs index fb67a2abb..28fed967e 100644 --- a/crates/functions/src/stat/tests.rs +++ b/crates/functions/src/stat/tests.rs @@ -433,6 +433,185 @@ fn linest_family() { is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); } +/// LINEST on columns of predictors, `stats` on, as rows of numbers (`None` for an error cell). +fn linest_rows(y: &[f64], xs: &[Vec], constant: bool) -> Vec>> { + let x = arr((0..y.len()).map(|i| xs.iter().map(|col| nv(col[i])).collect()).collect()); + let r = rows_of(&ev("LINEST", vec![c(y), av(x), b(constant), b(true)])); + r.iter().map(|row| row.iter().map(Value::as_f64).collect()).collect() +} + +fn rel_close(got: Option, want: f64, tol: f64, what: &str) { + let got = got.unwrap_or(f64::NAN); + assert!((got - want).abs() <= tol * want.abs().max(1e-300), "{what}: got {got:e}, want {want:e}, rel tol {tol:e}"); +} + +// B2: LINEST solved the normal equations, whose condition number is the square of the data's. +// Expected values below come from exact rational arithmetic: the normal equations solved with +// Python `fractions.Fraction` (no rounding at all), rounded once to f64 (script in the PR text). +#[test] +fn linest_ill_conditioned_polynomial_is_exact() { + // y = 1 + x + x² + x³ + x⁴ + x⁵ on x = 1..12: every coefficient is exactly 1. The normal + // equations were off by up to 2.6e-6. + let xs: Vec = (1..=12).map(f64::from).collect(); + let ys: Vec = xs.iter().map(|x| (0..=5).map(|j| x.powi(j)).sum()).collect(); + let cols: Vec> = (1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect(); + let r = linest_rows(&ys, &cols, true); + for (i, v) in r[0].iter().enumerate() { + rel_close(*v, 1.0, 1e-9, &format!("coefficient {i}")); + } +} + +#[test] +fn linest_large_offset_regressors_recover_the_line() { + // y = 3x + 7 exactly with x = 1e6 + i and regressors [x, x²]: the true fit has x² coefficient + // 0, x coefficient 3, intercept 7. The normal equations returned 2.99976 and 251.14. + let xs: Vec = (0..12).map(|i| 1_000_000.0 + f64::from(i)).collect(); + let ys: Vec = xs.iter().map(|x| 3.0 * x + 7.0).collect(); + let r = linest_rows(&ys, &[xs.clone(), xs.iter().map(|x| x * x).collect()], true); + assert!(r[0][0].unwrap().abs() < 1e-12, "x² coefficient {:?}", r[0][0]); + rel_close(r[0][1], 3.0, 1e-9, "x coefficient"); + // The intercept subtracts 3e6 from 3e6 + 7 and carries the x² coefficient times 1e12, so + // it is good to about 1e-4 in absolute terms, not better. + assert!((r[0][2].unwrap() - 7.0).abs() < 1e-3, "intercept {:?}", r[0][2]); + // One regressor: exact. + let one = linest_rows(&ys, std::slice::from_ref(&xs), true); + rel_close(one[0][0], 3.0, 1e-12, "slope"); + rel_close(one[0][1], 7.0, 1e-9, "intercept"); +} + +#[test] +fn linest_noisy_fits_match_exact_rational_least_squares() { + // Two predictors with noise, constant fitted. Order of each row: x2, x1, intercept. + let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let x2 = [2.0, 1.0, 4.0, 3.0, 7.0, 5.0, 9.0, 6.0]; + let y = [5.0, 6.0, 11.0, 12.0, 20.0, 19.0, 30.0, 28.0]; + let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], true); + let (coef, se) = ([1.4270152505446623, 2.452069716775599, -1.2592592592592593], [0.3376442444771937, 0.3679392851360571, 1.0847489958915801]); + for i in 0..3 { + rel_close(r[0][i], coef[i], 1e-12, "coef"); + rel_close(r[1][i], se[i], 1e-12, "se"); + } + rel_close(r[2][0], 0.985070139277778, 1e-12, "r2"); + rel_close(r[2][1], 1.367057173604725, 1e-12, "sey"); + rel_close(r[3][0], 164.94965318256004, 1e-11, "F"); + assert_eq!(r[3][1], Some(5.0)); + rel_close(r[4][0], 616.5307734204792, 1e-12, "ssreg"); + rel_close(r[4][1], 9.344226579520697, 1e-11, "ssresid"); + assert_eq!(r[2][2], None); + // const = FALSE: through the origin, R² and F from the uncentred sums. + let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], false); + rel_close(r[0][0], 1.3529411764705883, 1e-12, "coef"); + rel_close(r[0][1], 2.303921568627451, 1e-12, "coef"); + assert_eq!(r[0][2], Some(0.0)); + rel_close(r[1][0], 0.34102994374364876, 1e-12, "se"); + rel_close(r[1][1], 0.35495521934550084, 1e-12, "se"); + assert_eq!(r[1][2], None); + rel_close(r[2][0], 0.995718966041848, 1e-12, "r2"); + rel_close(r[2][1], 1.4061024795535124, 1e-12, "sey"); + rel_close(r[3][0], 697.7652892561983, 1e-11, "F"); + assert_eq!(r[3][1], Some(6.0)); + rel_close(r[4][0], 2759.1372549019607, 1e-12, "ssreg"); + rel_close(r[4][1], 11.862745098039216, 1e-11, "ssresid"); + // Cubic and quintic with noise on x = 1..12; order x^k .. x, intercept. + let xs: Vec = (1..=12).map(f64::from).collect(); + let noise = [3.0, -2.0, 4.0, -1.0, 2.0, -3.0, 1.0, -4.0, 2.0, 3.0, -2.0, 1.0]; + let cub: Vec = xs.iter().zip(noise).map(|(x, e)| 1.0 + x + x * x + x.powi(3) + e).collect(); + let r = linest_rows(&cub, &(1..=3).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::>>(), true); + let coef = [0.9955969955969955, 1.1452991452991452, -0.33514633514633513, 4.373737373737374]; + let se = [0.02755463331301788, 0.5434124199147191, 3.102549978874372, 4.851885058051787]; + for i in 0..4 { + rel_close(r[0][i], coef[i], 1e-11, "cubic coef"); + rel_close(r[1][i], se[i], 1e-10, "cubic se"); + } + rel_close(r[2][0], 0.9999837292113533, 1e-12, "cubic r2"); + rel_close(r[4][1], 70.35586635586635, 1e-9, "cubic ssresid"); + let quint: Vec = xs.iter().zip(noise).map(|(x, e)| (0..=5).map(|j| x.powi(j)).sum::() + 50.0 * e).collect(); + let r = linest_rows(&quint, &(1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::>>(), true); + let coef = [0.9330693815987934, 3.022273070067188, -20.97398190045249, 108.20489681886741, -250.24940010969422, 271.45454545454544]; + let se = [0.20046539191103213, 6.538253746916523, 78.39635356777087, 421.2608646012634, 976.3504976093745, 736.5866411764556]; + for i in 0..6 { + rel_close(r[0][i], coef[i], 1e-8, "quintic coef"); + rel_close(r[1][i], se[i], 1e-8, "quintic se"); + } + rel_close(r[2][1], 168.58174051770763, 1e-9, "quintic sey"); + rel_close(r[4][1], 170518.81941587824, 1e-7, "quintic ssresid"); +} + +#[test] +fn linest_drops_collinear_columns() { + // x2 = 2·x1: the later column is dropped (coefficient and standard error 0), the fit is the + // one on x1 alone, and the degrees of freedom count only the kept column. + let x1 = [1.0, 2.0, 3.0, 4.0, 5.0]; + let y = [2.0, 4.0, 5.0, 4.0, 5.0]; + let x2: Vec = x1.iter().map(|v| 2.0 * v).collect(); + let r = linest_rows(&y, &[x1.to_vec(), x2], true); + // Exact: slope 0.6, intercept 2.2, ssresid 2.4, ssreg 3.6, df 3 (Fraction arithmetic). + assert_eq!(r[0][0], Some(0.0)); + rel_close(r[0][1], 0.6, 1e-12, "slope"); + rel_close(r[0][2], 2.2, 1e-12, "intercept"); + assert_eq!(r[1][0], Some(0.0)); + rel_close(r[1][1], (0.8f64 / 10.0).sqrt(), 1e-12, "se slope"); + assert_eq!(r[3][1], Some(3.0)); + rel_close(r[4][0], 3.6, 1e-12, "ssreg"); + rel_close(r[4][1], 2.4, 1e-12, "ssresid"); + // A constant column is collinear with the intercept and dropped as well. + let r = linest_rows(&y, &[vec![7.0; 5], x1.to_vec()], true); + assert_eq!(r[0][1], Some(0.0)); + assert_eq!(r[1][1], Some(0.0)); + rel_close(r[0][0], 0.6, 1e-12, "slope"); + rel_close(r[0][2], 2.2, 1e-12, "intercept"); + // A constant column whose mean does not centre to exactly 0 (the mean of three 0.1 is + // 0.10000000000000002) is just as collinear with the intercept: dropped, not scaled up to a + // unit vector. Some length hits a rounding mean for each constant. + for c in [0.1, 1.1, 1e6 + 0.3] { + for n in 3..=12 { + let xs: Vec = (1..=n).map(f64::from).collect(); + let ys: Vec = (1..=n).map(|i| f64::from(i * i % 7) + 0.5 * f64::from(i)).collect(); + let r = linest_rows(&ys, &[vec![c; n as usize], xs], true); + assert_eq!(r[0][1], Some(0.0), "coefficient of the constant {c}, n={n}"); + assert_eq!(r[1][1], Some(0.0), "standard error of the constant {c}, n={n}"); + assert_eq!(r[3][1], Some(f64::from(n) - 2.0), "df with the constant {c}, n={n}"); + } + } + // A column that merely sits far from zero is not noise and is kept. + let off: Vec = x1.iter().map(|v| 1e9 + v).collect(); + let r = linest_rows(&y, &[off], true); + rel_close(r[0][0], 0.6, 1e-6, "slope on an offset column"); + assert_eq!(r[3][1], Some(3.0)); + // Distinct values far from zero, whose spread is below 1e-12 of the offset, still vary. + let r = linest_rows(&[0.0, 1.0, 2.0], &[vec![1e13, 1e13 + 1.0, 1e13 + 2.0]], true); + rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e13 offset"); + assert_eq!(r[3][1], Some(1.0), "df: the predictor is kept"); + // Same at 1e15, where the spread is only a few ulps of the offset: still distinct doubles. + let x15 = [1e15, 1e15 + 1.0, 1e15 + 2.0]; + let r = linest_rows(&[0.0, 1.0, 2.0], &[x15.to_vec()], true); + rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e15 offset"); + rel_close(r[0][1], -1e15, 1e-9, "intercept on a predictor with a 1e15 offset"); + assert_eq!(r[3][1], Some(1.0), "df: the 1e15 predictor is kept"); + assert!(r[4][1].is_some_and(|v| v.abs() < 1e-9), "ssresid: the 1e15 line is exact, got {:?}", r[4][1]); + let fitted = rows_of(&ev("TREND", vec![c(&[0.0, 1.0, 2.0]), c(&x15)])); + close(fitted[2][0].clone(), 2.0); + // More predictors than observations: the surplus columns drop, no panic. + let r = linest_rows(&[1.0, 2.0], &[vec![1.0, 2.0], vec![2.0, 1.0], vec![0.0, 5.0]], true); + assert_eq!(r[0].len(), 4); +} + +#[test] +fn logest_is_linest_of_ln_y() { + // y = 5·2^x: m = 2, b = 5. + let x = [0.0, 1.0, 2.0, 3.0, 4.0]; + let y: Vec = x.iter().map(|v| 5.0 * 2f64.powf(*v)).collect(); + let l = rows_of(&ev("LOGEST", vec![c(&y), c(&x)])); + close_tol(l[0][0].clone(), 2.0, 1e-12); + close_tol(l[0][1].clone(), 5.0, 1e-12); + // The statistics are those of the fit on ln y, untransformed. + let ln: Vec = y.iter().map(|v| v.ln()).collect(); + let ls = rows_of(&ev("LOGEST", vec![c(&y), c(&x), b(true), b(true)])); + let ns = rows_of(&ev("LINEST", vec![c(&ln), c(&x), b(true), b(true)])); + assert_eq!(ls[1..], ns[1..]); + close_tol(ls[0][0].clone(), ns[0][0].as_f64().unwrap().exp(), 1e-14); +} + // #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) // inversion; a wider TOROW aborted the process. It is now refused before allocating. #[test] diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c77b612f7..e345ee0ee 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -53,6 +53,15 @@ impl Remote { self.conn = None; let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); for sa in self.addr.to_socket_addrs()? { + // The token goes over plain TCP, so only to this machine: a network peer could read it, + // and the app's control port listens on loopback only. + if !sa.ip().is_loopback() { + last = std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{sa} is not a loopback address: the control token is sent only to 127.0.0.1 or ::1"), + ); + continue; + } match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) { Ok(s) => { s.set_nodelay(true).ok(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 5a5297246..0f9dd665f 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -482,6 +482,9 @@ fn remote_backend_over_tcp() { drop(s); handle.join().unwrap(); assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err()); + // A network address is refused before anything, the token included, is sent. + let err = Remote::connect_with_token("192.0.2.1:7979", "x".into()).err().unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied, "{err}"); } #[test] diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 8ab74606a..5c9f961d7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -668,6 +668,9 @@ pub enum ShapeKind { /// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the /// anchor (sheet points), `Shape::line` the ink colour and `fill` unused. Ink, + /// Decorative text (`insert.wordArt`): a transparent shape whose `Shape::text` is drawn with + /// the large, filled/outlined/shadowed text properties of `Shape::format`. + WordArt, /// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour /// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout. Equation, @@ -1026,6 +1029,8 @@ pub struct ShapeFormat { pub text_color: Option, /// Text outline (`a:rPr/a:ln`): colour and width in points. pub text_outline: Option<(Color, f32)>, + /// Text drop shadow colour (`a:rPr/a:effectLst/a:outerShdw`); `None` is no shadow. + pub text_shadow: Option, /// `None`: the shape's default (left for text boxes, centred for other shapes). pub align: Option, /// `None`: the shape's default (top for text boxes, middle for other shapes). diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index e2da31e43..21406efa7 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -235,11 +235,12 @@ impl CellStore { bytes = bytes.saturating_add(size_of::()); for (row, cols) in &band.rows { bytes = bytes.saturating_add(size_of::() + 3 * size_of::()); + let next_row = next.and_then(|b| b.rows.get(row)); for (col, cell) in cols { // Cells and BTreeMap nodes are duplicated with a cloned band even when // their formula/value Arcs are still shared with the next snapshot. bytes = bytes.saturating_add(size_of::() + size_of::() + 3 * size_of::()); - let next_cell = next.and_then(|b| b.rows.get(row)).and_then(|r| r.get(col)); + let next_cell = next_row.and_then(|r| r.get(col)); if let Some(formula) = &cell.formula && !next_cell.and_then(|c| c.formula.as_ref()).is_some_and(|f| Arc::ptr_eq(f, formula)) { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 68983828f..b94aaf6a7 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -19,7 +19,10 @@ mod symbol; pub use symbol::{symbol_code, symbol_width}; +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; /// RGB colour, 0–255 per channel. pub type Rgb = [u8; 3]; @@ -322,6 +325,31 @@ fn n(v: f32) -> String { } } +/// Appends `v / 255` with at most three decimals, exactly what `n(v as f32 / 255.0)` gives, but +/// without a float round trip or an allocation per channel. +fn push_unit(s: &mut String, v: u8) { + let m = (u32::from(v) * 1000 + 127) / 255; + match m { + 0 => s.push('0'), + 1000 => s.push('1'), + _ => { + let _ = write!(s, "0.{m:03}"); + while s.ends_with('0') { + s.pop(); + } + } + } +} + +/// Appends a colour operator line without the trailing newline: `r g b rg` (fill) or `r g b RG`. +fn push_rgb(s: &mut String, c: Rgb, stroke: bool) { + for v in c { + push_unit(s, v); + s.push(' '); + } + s.push_str(if stroke { "RG" } else { "rg" }); +} + /// True when every value is finite (NaN and infinities are rejected). fn finite(v: &[f32]) -> bool { v.iter().all(|x| x.is_finite()) @@ -348,16 +376,44 @@ fn dash_values(dash: &[f32]) -> String { d.iter().map(|v| n(*v)).collect::>().join(" ") } -fn rgb_op(c: Rgb, stroke: bool) -> String { - let f = |v: u8| n(v as f32 / 255.0); - format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) -} - struct ImageData { width: u32, height: u32, rgb: Vec, + /// `None` when every pixel is fully opaque (no soft mask is written). alpha: Option>, + /// Hash of all of the above, to find duplicates quickly; equality still compares the bytes. + hash: u64, +} + +impl ImageData { + fn new(width: u32, height: u32, rgb: Vec, alpha: Option>) -> ImageData { + let mut h = DefaultHasher::new(); + width.hash(&mut h); + height.hash(&mut h); + rgb.hash(&mut h); + alpha.hash(&mut h); + ImageData { width, height, rgb, alpha, hash: h.finish() } + } + + /// Same pixels (a hash match alone is never trusted). + fn same(&self, o: &ImageData) -> bool { + self.hash == o.hash && self.width == o.width && self.height == o.height && self.rgb == o.rgb && self.alpha == o.alpha + } +} + +/// The part of the graphics state the writer tracks so that a style operator is only emitted +/// when it changes. `None` = not set yet on this page (or since the last restore that undid it). +#[derive(Clone, Debug, Default, PartialEq)] +struct Style { + fill: Option, + stroke: Option, + /// Formatted line width. + width: Option, + /// Formatted dash array contents. + dash: Option, + /// Line join; PDF's initial value is 0 (miter). + join: u8, } enum LinkTarget { @@ -378,8 +434,18 @@ pub struct Page { content: String, fonts: [bool; FONT_SLOTS], images: Vec, + /// Indexes into `images`, by content hash; a hash match is confirmed with [`ImageData::same`]. + image_index: HashMap>, + /// Pixel comparisons made while looking up duplicate pictures (test instrumentation). + #[cfg(test)] + image_comparisons: usize, + /// Style operators already in effect at the end of `content`. + style: Style, + /// One entry per accepted [`Page::save`], restored by [`Page::restore`]. + saved: Vec