diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml new file mode 100644 index 0000000..cd8f12a --- /dev/null +++ b/.github/workflows/flatpak-pages.yml @@ -0,0 +1,176 @@ +name: Publish Flatpak repository to Pages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Published GitHub release tag to publish (for example, v1.2.3)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: flatpak-pages-${{ github.repository }} + cancel-in-progress: false + +jobs: + build: + name: Build Flatpak repository + # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped + # here, and the validation step below refuses a manual tag with a pre-release suffix. + if: github.event_name != 'release' || !github.event.release.prerelease + runs-on: ubuntu-24.04 + permissions: + contents: read + # configure-pages only reads the Pages configuration (base_url); deploy needs write. + pages: read + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + APP_ID: ai.storyteller.gridcraft + steps: + - name: Configure Pages + id: pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Install Flatpak repository tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends flatpak ostree + + - name: Download and validate release bundles + shell: bash + run: | + set -euo pipefail + tag="$RELEASE_TAG" + if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" + exit 1 + fi + # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1). + case "${tag%%+*}" in + *-*) + echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository" + exit 1 + ;; + esac + published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ + 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')" + if [[ "$published_tag" != "$tag" ]]; then + echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY" + exit 1 + fi + + version="${tag#v}" + mkdir -p bundles + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern SHA256SUMS.txt --dir bundles + for arch in x86_64 aarch64; do + asset="gridcraft-$version-linux-$arch.flatpak" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset" --dir bundles + test -s "bundles/$asset" + ( + cd bundles + awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \ + SHA256SUMS.txt | sha256sum --check - + ) + done + test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2 + + for arch in x86_64 aarch64; do + asset="bundles/gridcraft-$version-linux-$arch.flatpak" + validation_repo="$(mktemp -d)" + ostree --repo="$validation_repo" init --mode=archive-z2 + flatpak build-import-bundle "$validation_repo" "$asset" + expected_ref="app/$APP_ID/$arch/stable" + ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || { + echo "::error::$(basename "$asset") does not contain $expected_ref" + exit 1 + } + rm -rf "$validation_repo" + done + + - name: Build repository and site + shell: bash + env: + PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} + run: | + set -euo pipefail + site="$RUNNER_TEMP/site" + repo_dir="$site/flatpak/repo" + mkdir -p "$repo_dir" + ostree --repo="$repo_dir" init --mode=archive-z2 + + previous_url="${PAGES_BASE_URL%/}/flatpak/repo" + if curl --fail --silent --show-error --location \ + --output /dev/null "$previous_url/summary"; then + ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url" + ostree --repo="$repo_dir" pull --mirror previous + ostree --repo="$repo_dir" remote delete previous + fi + + version="${RELEASE_TAG#v}" + for arch in x86_64 aarch64; do + flatpak build-import-bundle "$repo_dir" \ + "bundles/gridcraft-$version-linux-$arch.flatpak" + done + # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository + # (re-mirrored and re-deployed on every release) does not grow without bound. + flatpak build-update-repo \ + --prune --prune-depth=3 \ + --title="GridCraft" \ + --comment='Official GridCraft releases' \ + --description='Official Flatpak repository for GridCraft' \ + --homepage="https://github.com/$GITHUB_REPOSITORY" \ + --default-branch=stable \ + "$repo_dir" + + for arch in x86_64 aarch64; do + ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null + done + test -s "$repo_dir/summary" + + base_url="${PAGES_BASE_URL%/}" + repo_url="$base_url/flatpak/repo/" + cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repository

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
+          flatpak install gridcraft $APP_ID
+ HTML + cat >"$site/index.html" <GridCraft downloads

GridCraft downloads

Flatpak repository

Release assets

+ HTML + touch "$site/.nojekyll" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/site + include-hidden-files: true + + deploy: + name: Deploy Pages + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca270a6..6c9050a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -316,6 +316,75 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} + GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no # secrets, so the called workflow runs without the release environment (and in dry runs). @@ -330,7 +399,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, web, freebsd] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd] # Belt and braces for branch dry runs: only the release branch drafts a release (the # environment's branch rule refuses this job and the signing jobs anywhere else, too). if: github.ref == 'refs/heads/release' diff --git a/Cargo.lock b/Cargo.lock index 690b150..6ff01cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1121,6 +1122,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1132,6 +1139,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1289,7 +1302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1394,6 +1407,7 @@ dependencies = [ name = "gridcraft" version = "0.4.0" dependencies = [ + "arboard", "eframe", "egui", "gridcraft-engine", @@ -1466,6 +1480,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] @@ -1595,6 +1610,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1603,7 +1627,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1612,7 +1636,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2040,6 +2064,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2428,6 +2461,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -2476,6 +2519,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3366,6 +3420,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4233,6 +4298,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/README.md b/README.md index 1344bc6..d416eba 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. + [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, structured table references, Excel's full number-format language, sort and AutoFilter, @@ -115,6 +117,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +### Ribbon access keys + +Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform, +then type a sequence below one key at a time. Keytips appear on the supported Home +controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option +remains available for entering accented characters. + +| Sequence after Alt/F10 | Action | +|---|---| +| `H A L` / `H A C` / `H A R` | Align left / center / right | +| `H V S` or legacy `E S` | Open Paste Special | +| `H O I` or legacy `O C A` | AutoFit selected columns | + +In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles +Transpose. **Enter** applies the selected options; **Escape** cancels the dialog. +These letters also work with Alt held on Windows/Linux/BSD. + +Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling, +or switching windows cancels navigation. Access keys do not start while editing text +or using another dialog. This is initial support for these Home and legacy paths; +other ribbon commands and full mouse-free navigation remain to be implemented. +Browsers or desktop environments may reserve Alt/F10 for their own menus. + ### Logs The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder @@ -154,7 +179,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the |---|---| | L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) | | L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) | -| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) | +| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) | | L3 | `chart` (toolkit-free chart layout and rendering) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | | L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 561162f..c97b1d6 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -147,6 +147,14 @@ impl Args { fn exec(s: &mut Session, id: &str, params: Value) -> Result { let r = s.execute(id, params).map_err(|e| e.to_string()); s.take_ui_requests(); + if id == "file.open" + && let Ok(value) = &r + && let Some(warnings) = value.get("warnings").and_then(Value::as_array) + { + for warning in warnings.iter().filter_map(Value::as_str) { + eprintln!("Warning: {warning}"); + } + } r } diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 991c633..356c63b 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 9e36a07..48d308e 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -102,12 +102,15 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { + copy_html: Some(Box::new(copy_html)), open_async: Some(Box::new(move || { let inbox = open_inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let Some(file) = - rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .pick_file() + .await else { return; }; @@ -131,6 +134,39 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +/// Start inside the copy gesture; deferring `write` can lose browser user activation. +fn copy_html(html: &str, text: &str) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let clipboard = window.navigator().clipboard(); + if clipboard.is_undefined() || clipboard.is_null() { + return Err("browser clipboard is unavailable (a secure context is required)".into()); + } + if !js_sys::Reflect::get(&clipboard, &"write".into()).map_err(js)?.is_function() { + return Err("browser does not support rich clipboard writes".into()); + } + let data = js_sys::Object::new(); + for (mime, value) in [("text/html", html), ("text/plain", text)] { + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime); + let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(value)); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(js)?; + js_sys::Reflect::set(&data, &mime.into(), &blob).map_err(js)?; + } + let item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&data).map_err(js)?; + let promise = clipboard.write(&js_sys::Array::of1(&item)); + let text = text.to_owned(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::warn!("Rich clipboard write failed; trying plain text: {e:?}"); + if let Err(e) = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await { + log::error!("Plain-text clipboard fallback also failed: {e:?}"); + } + } + }); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 0d3e34c..66711fa 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -25,6 +25,10 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +arboard = { workspace = true, features = ["wayland-data-control"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index d73cd71..e3b56a8 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -262,6 +262,7 @@ impl log::Log for AppLogger { } // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. let thread = std::thread::current(); + #[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); if self.stderr { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8137b23..70c8368 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -112,11 +112,21 @@ fn save_prefs(app: &SheetApp) { } fn services() -> Services { + // Keep the owner alive: X11/Wayland serve clipboard data from this handle. + let mut clipboard: Option = None; Services { + copy_html: Some(Box::new(move |html, text| { + if clipboard.is_none() { + clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + } + clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) + .add_filter("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) .pick_file() .and_then(|p| p.to_str().map(str::to_string)) @@ -225,6 +235,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); let mut viewport = egui::ViewportBuilder::default() diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 260d900..621b27a 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -249,6 +249,31 @@ impl NativeMenu { pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + // Text selection belongs to the inline editor, not the worksheet clipboard/undo. + if app.text_box_editor.is_some() { + let clipboard = match id.as_str() { + "edit.copy" => Some(egui::ViewportCommand::RequestCopy), + "edit.cut" => Some(egui::ViewportCommand::RequestCut), + "edit.paste" => Some(egui::ViewportCommand::RequestPaste), + _ => None, + }; + if let Some(command) = clipboard { + ctx.send_viewport_cmd(command); + continue; + } + if id == "edit.undo" || id == "edit.redo" { + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::Z, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: id == "edit.redo", ..Default::default() }, + }); + }); + continue; + } + } if select_theme(app, &id) { continue; } @@ -269,11 +294,7 @@ impl NativeMenu { let name = if name == "note" { "comment" } else { name }; app.open_dialog(name, params); } else if id == "edit.copy" || id == "edit.cut" { - if let Ok(r) = app.run(&id, json!({})) - && let Some(t) = r.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, &id); } else { app.run_or_alert(&id, json!({})); } diff --git a/clippy.toml b/clippy.toml index 4b6c79f..8b8bc5a 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,11 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true + +# The web app (wasm32-unknown-unknown) has no clock: these trap (`RuntimeError: unreachable`) +# instead of returning. Read the clock only off wasm (`cfg(not(target_arch = "wasm32"))` or +# `cfg!`) and allow the lint at that call. +disallowed-methods = [ + { path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" }, + { path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" }, +] diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index e36be2d..72791ab 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -62,6 +62,11 @@ pub trait Host { fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { range.iter().map(|c| self.cell_value(sheet, c)).collect() } + /// The sheet's used range (`Sheet::used_range`, which walks every stored row). Hosts that + /// keep the workbook unchanged while evaluating can remember it. + fn used_range(&mut self, sheet: usize) -> Option { + self.workbook().sheet(sheet).and_then(|s| s.used_range()) + } } pub struct Evaluator<'h> { @@ -144,9 +149,11 @@ impl<'h> Evaluator<'h> { /// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so /// positions stay right). - pub fn trim(&self, a: &Area) -> RangeRef { - let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range }; - let used = sheet.used_range(); + pub fn trim(&mut self, a: &Area) -> RangeRef { + if self.host.workbook().sheet(a.sheet).is_none() { + return a.range; + } + let used = self.host.used_range(a.sheet); let (mut r1, mut c1) = (a.range.end.row, a.range.end.col); match used { Some(u) => { @@ -910,7 +917,7 @@ impl<'h> Evaluator<'h> { } /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. - fn trim_lines(&self, a: &Area) -> RangeRef { + fn trim_lines(&mut self, a: &Area) -> RangeRef { if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } } diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 500dec2..24cfabc 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1,13 +1,14 @@ //! Dependency graph and recalculation. //! //! The graph maps every formula cell to its static precedents (resolved areas). A reverse index -//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in -//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the +//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges as row +//! intervals per column (wide ranges per sheet), each an interval index so a lookup costs +//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the //! transitive closure of dependents; it is evaluated in topological order, with on-demand //! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative //! calculation is on. -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::hash::{BuildHasherDefault, Hasher}; /// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). @@ -51,19 +52,147 @@ struct Node { dynamic: bool, } -/// Ranges wider than this many columns go to the wide list instead of per-column buckets. +/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. const WIDE: u32 = 64; +/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. +const PENDING: usize = 32; + +/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows +/// `a..=b`?" in O(log n + k). +/// +/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query +/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one +/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops +/// dead entries once either grows, so mutations cost O(log n) amortised. +#[derive(Clone, Debug)] +struct Intervals { + /// Sorted, without duplicates. + items: Vec<(u32, u32, T)>, + dead: Vec, + dead_count: usize, + /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at + /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). + max_end: Vec, + /// Inserted since the last merge. + pending: BTreeSet<(u32, u32, T)>, +} + +impl Default for Intervals { + fn default() -> Self { + Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } + } +} + +impl Intervals { + fn is_empty(&self) -> bool { + self.items.len() == self.dead_count && self.pending.is_empty() + } + + fn insert(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && *d + { + *d = false; + self.dead_count -= 1; + } + return; + } + self.pending.insert(it); + } + + fn remove(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && !*d + { + *d = true; + self.dead_count += 1; + } + return; + } + self.pending.remove(&it); + } + + /// Merges pending insertions and drops dead entries when there are enough of them. + fn settle(&mut self) { + if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { + return; + } + let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); + all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); + all.extend(std::mem::take(&mut self.pending)); + // Two sorted runs: the stable sort merges them in linear time. + all.sort(); + all.dedup(); + let size = all.len().next_power_of_two(); + let mut max_end = vec![0; 2 * size]; + if let Some(leaves) = max_end.get_mut(size..) { + for (slot, it) in leaves.iter_mut().zip(&all) { + *slot = it.1; + } + } + for i in (1..size).rev() { + let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); + if let Some(slot) = max_end.get_mut(i) { + *slot = m; + } + } + self.dead = vec![false; all.len()]; + self.dead_count = 0; + self.items = all; + self.max_end = max_end; + } + + /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the + /// number of tree nodes visited. + fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + // Entries before `p` start at or before row `b`. + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return 0; + } + self.walk(1, 0, self.max_end.len() / 2, p, a, f) + } + + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return 1; + } + if hi - lo <= 1 { + if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) + && it.1 >= a + { + f(&it.2); + } + return 1; + } + let mid = lo + (hi - lo) / 2; + 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) + } +} + #[derive(Clone, Debug, Default)] pub struct Graph { nodes: HashMap, /// single cell → dependents - cell_deps: HashMap>, - /// (sheet, col) → [(r0, r1, dependent)] - col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>, - /// (sheet, range, dependent) - wide_deps: Vec<(usize, RangeRef, Key)>, + cell_deps: HashMap>, + /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column + col_deps: HashMap<(usize, u32), Intervals>, + /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) + wide_deps: HashMap>, dynamic: HashSet, + /// Range indexes changed since the last [`Graph::settle`]. + unsettled_cols: HashSet<(usize, u32)>, + unsettled_wide: HashSet, } impl Graph { @@ -74,19 +203,44 @@ impl Graph { self.nodes.is_empty() } - fn insert(&mut self, k: Key, node: Node) { - self.remove(k); - for a in &node.areas { + /// Index entries of a formula's precedents, without duplicates: single cells, column + /// intervals and wide ranges. + fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { + let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for a in areas { if a.range.is_single() { - self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k); + cells.push((a.sheet, a.range.start)); } else if a.range.width() > WIDE { - self.wide_deps.push((a.sheet, a.range, k)); + wide.push((a.sheet, a.range)); } else { for c in a.range.start.col..=a.range.end.col { - self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k)); + cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); } } } + cells.sort_unstable(); + cells.dedup(); + cols.sort_unstable(); + cols.dedup(); + wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); + wide.dedup(); + (cells, cols, wide) + } + + fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + let (cells, cols, wide) = Self::entries(&node.areas); + for c in cells { + self.cell_deps.entry(c).or_default().insert(k); + } + for (s, c, r0, r1) in cols { + self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + for (s, r) in wide { + self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } if node.dynamic { self.dynamic.insert(k); } @@ -95,54 +249,104 @@ impl Graph { fn remove(&mut self, k: Key) { let Some(old) = self.nodes.remove(&k) else { return }; - for a in &old.areas { - if a.range.is_single() { - if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) { - v.retain(|x| *x != k); - } - } else if a.range.width() > WIDE { - self.wide_deps.retain(|(_, _, d)| *d != k); - } else { - for c in a.range.start.col..=a.range.end.col { - if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) { - v.retain(|(_, _, d)| *d != k); - } + let (cells, cols, wide) = Self::entries(&old.areas); + for c in cells { + if let Some(v) = self.cell_deps.get_mut(&c) { + v.remove(&k); + if v.is_empty() { + self.cell_deps.remove(&c); } } } + for (s, c, r0, r1) in cols { + if let Some(v) = self.col_deps.get_mut(&(s, c)) { + v.remove(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + } + for (s, r) in wide { + if let Some(v) = self.wide_deps.get_mut(&s) { + v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + } self.dynamic.remove(&k); } + /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, + /// just slower). + fn settle(&mut self) { + for key in std::mem::take(&mut self.unsettled_cols) { + if let Some(v) = self.col_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.col_deps.remove(&key); + } + } + } + for key in std::mem::take(&mut self.unsettled_wide) { + if let Some(v) = self.wide_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.wide_deps.remove(&key); + } + } + } + } + /// Formulas that read cell `c` of `sheet`. pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend_from_slice(v); + out.extend(v.iter().copied()); } if let Some(v) = self.col_deps.get(&(sheet, c.col)) { - out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k)); + v.query(c.row, c.row, &mut |k| out.push(*k)); + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(c.row, c.row, &mut |(c0, c1, k)| { + if *c0 <= c.col && c.col <= *c1 { + out.push(*k); + } + }); } - out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k)); } - /// Formulas that read any cell of `range`. + /// Formulas that read any cell of `range` (a formula can be listed more than once). pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { if range.count() <= 4096 { for c in range.iter() { - self.dependents(sheet, c, out); + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } } - return; - } - for ((s, c), v) in &self.cell_deps { - if *s == sheet && range.contains(*c) { - out.extend_from_slice(v); + } else { + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend(v.iter().copied()); + } } } - for ((s, col), v) in &self.col_deps { - if *s == sheet && range.start.col <= *col && *col <= range.end.col { - out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k)); + let (r0, r1) = (range.start.row, range.end.row); + if (range.width() as usize) <= self.col_deps.len() { + for col in range.start.col..=range.end.col { + if let Some(v) = self.col_deps.get(&(sheet, col)) { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } else { + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + v.query(r0, r1, &mut |k| out.push(*k)); + } } } - out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k)); + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(r0, r1, &mut |(c0, c1, k)| { + if *c0 <= range.end.col && range.start.col <= *c1 { + out.push(*k); + } + }); + } } /// Precedent areas of a formula cell (for Trace Precedents). @@ -177,6 +381,9 @@ struct PassHost<'a> { in_progress: HashSet, /// New spills found during the pass: anchor → array. spills: HashMap>, + /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks + /// every row, so once per sheet rather than once per range read. + used: HashMap>, rng: &'a mut u64, now: f64, cycle: bool, @@ -321,6 +528,10 @@ impl Host for PassHost<'_> { } out } + fn used_range(&mut self, sheet: usize) -> Option { + let wb = self.wb; + *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) + } fn now_serial(&self) -> f64 { self.now } @@ -360,6 +571,7 @@ impl Calc { } } } + self.graph.settle(); } /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading). @@ -389,6 +601,7 @@ impl Calc { } } } + self.graph.settle(); if wb.calc.mode == CalcMode::Manual { // Only the edited formulas themselves are evaluated. let own: Vec = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect(); @@ -486,6 +699,7 @@ impl Calc { pending: exprs.keys().copied().collect(), in_progress: HashSet::default(), spills: HashMap::default(), + used: HashMap::default(), rng: &mut rng, now, cycle: false, @@ -573,6 +787,7 @@ impl Calc { } /// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date. +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_serial() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -602,6 +817,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V pending: HashSet::default(), in_progress: HashSet::default(), spills: HashMap::default(), + used: HashMap::default(), rng: &mut rng, now: now_serial(), cycle: false, @@ -612,6 +828,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -622,3 +839,213 @@ fn prof_now() -> f64 { 0.0 } } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gridcraft_core::{CellRef, RangeRef, Value}; + use gridcraft_model::{Cell, Formula, Workbook}; + + use super::{Calc, Intervals, Key}; + + /// xorshift64 for repeatable pseudo-random cases. + fn rng(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + } + + fn found(iv: &Intervals, a: u32, b: u32) -> Vec { + let mut v = Vec::new(); + iv.query(a, b, &mut |t| v.push(*t)); + v.sort_unstable(); + v + } + + #[test] + fn intervals_match_a_linear_scan() { + let mut iv: Intervals = Intervals::default(); + let mut model: Vec<(u32, u32, u32)> = Vec::new(); + let mut s = 0x9E37_79B9_7F4A_7C15; + for step in 0..20_000u32 { + let roll = rng(&mut s) % 10; + if roll < 6 || model.is_empty() { + let r0 = (rng(&mut s) % 500) as u32; + let r1 = r0 + (rng(&mut s) % 40) as u32; + if !model.contains(&(r0, r1, step)) { + iv.insert(r0, r1, step); + model.push((r0, r1, step)); + } + } else { + let i = (rng(&mut s) % model.len() as u64) as usize; + let (r0, r1, t) = model.swap_remove(i); + iv.remove(r0, r1, t); + if roll == 9 { + // Removed and put back before the next merge. + iv.insert(r0, r1, t); + model.push((r0, r1, t)); + } + } + if rng(&mut s).is_multiple_of(50) { + iv.settle(); + } + if step.is_multiple_of(97) { + let a = (rng(&mut s) % 560) as u32; + let b = a + (rng(&mut s) % 30) as u32; + let mut want: Vec = model.iter().filter(|(r0, r1, _)| *r0 <= b && *r1 >= a).map(|(_, _, t)| *t).collect(); + want.sort_unstable(); + assert_eq!(found(&iv, a, b), want, "rows {a}..={b} at step {step}"); + } + } + iv.settle(); + for row in 0..560 { + let mut want: Vec = model.iter().filter(|(r0, r1, _)| *r0 <= row && row <= *r1).map(|(_, _, t)| *t).collect(); + want.sort_unstable(); + assert_eq!(found(&iv, row, row), want, "row {row}"); + } + for (r0, r1, t) in std::mem::take(&mut model) { + iv.remove(r0, r1, t); + } + iv.settle(); + assert!(iv.is_empty()); + } + + #[test] + fn interval_queries_cost_log_n_plus_matches() { + // 40k one-row ranges (like =SUM(E1:J1) down a column) and 1k ranges over everything: + // a point query visits O(log n) tree nodes per match, not every range in the column. + let n = 40_000u32; + let mut iv: Intervals = Intervals::default(); + for r in 0..n { + iv.insert(r, r, r); + } + for t in 0..1_000 { + iv.insert(0, n, n + t); + } + iv.settle(); + let mut worst = 0; + for row in (0..n).step_by(7) { + let mut k = 0; + let visited = iv.query(row, row, &mut |_| k += 1); + assert_eq!(k, 1_001); + worst = worst.max(visited); + } + // 1,001 matches in a tree of depth 16: well under 4 nodes per match plus the path. + assert!(worst < 4 * 1_001 + 64, "visited {worst} nodes"); + let mut k = 0; + let visited = iv.query(n + 1, n + 5, &mut |_| k += 1); + assert_eq!(k, 0); + assert!(visited < 64, "visited {visited} nodes for no match"); + } + + /// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K. + fn sum_rows(rows: u32) -> Workbook { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + for r in 0..rows { + for c in 4..10 { + sh.cells.set(CellRef::new(r, c), Cell::value(Value::Number(f64::from(r % 97 + c)))); + } + sh.cells.set(CellRef::new(r, 10), Cell::formula(Formula::new(&format!("=SUM(E{0}:J{0})", r + 1)))); + } + } + wb + } + + /// Opening (full recalc), changing every value of a column, and re-entering every formula. + #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm + fn open_and_bulk_edit(rows: u32) -> Duration { + let mut wb = sum_rows(rows); + let mut calc = Calc::new(); + let t = Instant::now(); + calc.recalc_all(&mut wb); + let values: Vec = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect(); + if let Some(sh) = wb.sheet_mut(0) { + for (_, c) in &values { + sh.cells.set(*c, Cell::value(Value::Number(1.0))); + } + } + calc.cells_changed(&mut wb, &values); + let formulas: Vec = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect(); + calc.cells_changed(&mut wb, &formulas); + let elapsed = t.elapsed(); + let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10))); + let r = rows - 1; + assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::())))); + elapsed + } + + #[test] + fn range_formulas_recalc_and_bulk_edit_correctly() { + // The values behind the timing test below, checked on every run. + open_and_bulk_edit(500); + } + + #[test] + #[ignore = "timing-sensitive; run with --ignored in release"] + #[allow(clippy::disallowed_methods)] + fn recalc_with_range_formulas_scales_linearly() { + // Each row's SUM reads a range in the same columns. Recalculating on open used to walk + // every row per range read (the used range), and finding a cell's dependents scanned + // every range in its column: 4x the rows took 16x the time. Linear is 4x; allow 8x. + let best = |rows| (0..3).map(|_| open_and_bulk_edit(rows)).min().unwrap_or_default(); + let small = best(5_000); + let large = best(20_000); + assert!(large < small * 8 + Duration::from_millis(100), "5k rows {small:?}, 20k rows {large:?}"); + } + + #[test] + fn range_dependents_after_edits() { + // Formulas that read ranges, wide ranges and whole columns, found again after edits. + let mut wb = Workbook::new(); + let mut calc = Calc::new(); + let set = |wb: &mut Workbook, calc: &mut Calc, at: &str, input: &str| { + let c = CellRef::parse(at).unwrap(); + let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(Value::Number(input.parse().unwrap())) }; + wb.sheet_mut(0).unwrap().cells.set(c, cell); + calc.cells_changed(wb, &[(0, c)]); + }; + let num = |wb: &Workbook, at: &str| { + let v = wb.sheet(0).unwrap().value(CellRef::parse(at).unwrap()); + v.as_f64().unwrap_or_else(|| panic!("{at} = {v:?}")) + }; + set(&mut wb, &mut calc, "A1", "1"); + set(&mut wb, &mut calc, "B2", "2"); + set(&mut wb, &mut calc, "CZ3", "3"); + set(&mut wb, &mut calc, "DA5", "=SUM(A1:B2)"); + set(&mut wb, &mut calc, "DA6", "=SUM(A1:CZ3)"); + set(&mut wb, &mut calc, "DA7", "=SUM(B:B)"); + set(&mut wb, &mut calc, "DA8", "=SUM(2:2)"); + assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (3.0, 6.0, 2.0, 2.0)); + set(&mut wb, &mut calc, "B2", "20"); + assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (21.0, 24.0, 20.0, 20.0)); + // Re-entering a formula with another range drops the old one. + set(&mut wb, &mut calc, "DA5", "=SUM(A1:A2)"); + set(&mut wb, &mut calc, "B2", "5"); + assert_eq!(num(&wb, "DA5"), 1.0); + let mut deps = Vec::new(); + calc.graph.dependents(0, CellRef::parse("B2").unwrap(), &mut deps); + deps.sort_unstable(); + let at = |s: &str| (0, CellRef::parse(s).unwrap()); + assert_eq!(deps, vec![at("DA6"), at("DA7"), at("DA8")]); + deps.clear(); + calc.graph.dependents_of_range(0, RangeRef::new(CellRef::parse("A1").unwrap(), CellRef::parse("A9").unwrap()), &mut deps); + deps.sort_unstable(); + deps.dedup(); + assert_eq!(deps, vec![at("DA5"), at("DA6"), at("DA8")]); + set(&mut wb, &mut calc, "CZ3", "30"); + assert_eq!(num(&wb, "DA6"), 36.0); + // Ranges on another sheet. + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data"))); + set(&mut wb, &mut calc, "DA9", "=SUM(Data!A1:A3)+SUM(Data!A:CZ)"); + assert_eq!(num(&wb, "DA9"), 0.0); + wb.sheet_mut(1).unwrap().cells.set(CellRef::parse("A2").unwrap(), Cell::value(Value::Number(4.0))); + calc.cells_changed(&mut wb, &[(1, CellRef::parse("A2").unwrap())]); + assert_eq!(num(&wb, "DA9"), 8.0); + deps.clear(); + calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps); + assert_eq!(deps, vec![at("DA9"), at("DA9")]); + } +} diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index 5d0be7a..7169ae5 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -275,6 +275,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { /// The year used for dates typed without one. Fixed per process from the system clock when /// available (wasm without clock falls back to 2026). +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn current_year() -> i64 { #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 53e2350..b559b83 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/examples/perf.rs b/crates/engine/examples/perf.rs index 2a533a2..61ecf56 100644 --- a/crates/engine/examples/perf.rs +++ b/crates/engine/examples/perf.rs @@ -1,6 +1,8 @@ //! Performance scenarios on large workbooks (release build): //! `cargo run --release -p gridcraft-engine --example perf -- [rows]` +#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it + use std::time::Instant; use gridcraft_engine::Session; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9b7db40..1c5e54a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -40,8 +40,8 @@ pub fn specs() -> Vec { cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special), cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy), - cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy), + cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut), cmd!( "edit.paste", "Paste", @@ -611,9 +611,16 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { range }; let text = crate::display::range_text(&d.wb, sheet, range); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); - Ok(json!({"range": range.a1(), "text": text})) + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) } fn copy(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 34b957c..d52418b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string); + let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) + || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb); + let title = if imported { + format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) + } else { + std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string() + }; + // An import has no save target: ordinary Save must ask for a new destination. + let path = str_param(p, "path").filter(|_| !imported).map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) @@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); + if !warnings.is_empty() { + let mut message = warnings.iter().take(5).cloned().collect::>().join("\n\n"); + if warnings.len() > 5 { + message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5)); + } + s.ui_requests.push(crate::UiRequest::Message(message)); + } let d = s.doc()?; Ok( json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e4b5bc9..9c383fa 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -122,6 +122,7 @@ pub fn specs() -> Vec { s, &with(p, "kind", json!("textBox")) )), + cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object), cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object), cmd!( @@ -776,6 +777,37 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { }) } +/// A text box holds at most as many characters as a cell. +const SHAPE_TEXT_MAX_CHARS: usize = 32_767; + +fn shape_set_text(s: &mut Session, p: &Json) -> Result { + let id = + p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?; + let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?; + if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() { + return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); + } + let d = s.doc()?; + 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 { + return Err(bad("shape.setText", "the shape is not a text box")); + } + // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). + if sh.is_protected() { + return Err(EngineError::Other(PROTECTED.into())); + } + if shape.text == text { + return ok(); + } + edit(s, |cx| { + let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; + shape.text = text.into(); + Ok(Json::Null) + }) +} + fn delete_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a32401b..bc6763a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -220,8 +220,10 @@ pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> } pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) -> Result { - let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some(); - let t0 = std::time::Instant::now(); + // `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there + // `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app. + #[allow(clippy::disallowed_methods)] + let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; let mut sel = d.selection.clone(); let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; let r = f(&mut ctx)?; @@ -232,7 +234,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len()); } if structural { @@ -240,7 +242,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } else if !changed.is_empty() { d.calc.cells_changed(&mut wb, &changed); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: + recalc {:?}", t0.elapsed()); } fit_rows.sort_unstable(); @@ -248,7 +250,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) for (si, row) in fit_rows { auto_row_height(&mut wb, si, row); } - if prof { + if let Some(t0) = t0 { eprintln!("commit: + rows {:?}", t0.elapsed()); } d.wb = std::sync::Arc::new(wb); diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index e32422a..a604f0b 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -74,7 +74,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; let mut out = String::new(); - let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }; + let r = clipboard_range(r); for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; @@ -99,6 +99,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { out } +/// The existing plain-text clipboard limit, shared with its HTML representation. +pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef { + if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r } +} + /// Status-bar statistics for a selection: average, count (non-empty), numerical count, min, /// max, sum. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aef3681..820b2e6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,9 +1,9 @@ -//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; -use gridcraft_core::CellRef; -use gridcraft_model::Workbook; +use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; @@ -11,6 +11,8 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + Xlsb, + Ods, Csv, Tsv, Json, @@ -22,6 +24,8 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "xlsb" => FileKind::Xlsb, + "ods" => FileKind::Ods, "csv" => FileKind::Csv, "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, @@ -36,6 +40,18 @@ impl FileKind { pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); + // Content wins over the extension: an ODS or XLSB package is imported as what it is, + // whatever it is called; the extension only decides when the bytes don't say. + let import = match sniffed { + gridcraft_xlsx::Format::Ods => Some(FileKind::Ods), + gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb), + _ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)), + }; + if let Some(import) = import { + let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb }; + let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) { let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); @@ -81,6 +97,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), + FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), @@ -92,6 +110,109 @@ fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String { + let st = wb.styles.get(sh.style_id(c)); + let mut css = String::new(); + if st.font.bold { + css.push_str("font-weight:bold;"); + } + if st.font.italic { + css.push_str("font-style:italic;"); + } + if let Some(h) = st.font.color.hex(&wb.theme) { + let _ = write!(css, "color:{h};"); + } + if st.fill.pattern != gridcraft_model::PatternType::None + && let Some(h) = st.fill.fg.hex(&wb.theme) + { + let _ = write!(css, "background:{h};"); + } + let v = sh.value(c); + let align = match st.align.h { + gridcraft_model::HAlign::Center => "center", + gridcraft_model::HAlign::Right => "right", + gridcraft_model::HAlign::Left => "left", + _ if v.is_number() => "right", + _ => "left", + }; + let _ = write!(css, "text-align:{align};"); + css +} + +const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024; +const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000; + +/// 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 { + let r = crate::display::clipboard_range(r); + if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES { + return None; + } + let sh = wb.sheet(sheet)?; + let mut out = ClipboardHtml(String::new()); + out.write_str("").ok()?; + for row in r.start.row..=r.end.row { + if sh.is_row_hidden(row) { + continue; + } + 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 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; + } + let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count(); + format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width()) + } else { + String::new() + }; + let css = html_cell_css(wb, sh, c); + write!(out, "").ok()?; + } + out.write_str("").ok()?; + } + out.write_str("
").ok()?; + // Use the visible selected cell, even when the merge's original anchor + // is outside the range or hidden. This matches the plain-text copy. + let text = crate::display::cell_text(wb, sh, c); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '&' => out.write_str("&"), + '<' => out.write_str("<"), + '>' => out.write_str(">"), + '"' => out.write_str("""), + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + out.write_str("
") + } + '\n' => out.write_str("
"), + _ => out.write_char(ch), + } + .ok()?; + } + out.write_str("
").ok()?; + Some(out.0) +} + +/// Enforce the budget while escaping, before HTML expansion can allocate a large string. +struct ClipboardHtml(String); + +impl std::fmt::Write for ClipboardHtml { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) { + return Err(std::fmt::Error); + } + self.0.push_str(s); + Ok(()) + } +} + /// A sheet as a standalone HTML table (Save as Web Page). pub fn to_html(wb: &Workbook, sheet: usize) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; @@ -112,31 +233,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { continue; } - let st = wb.styles.get(sh.style_id(c)); - let mut css = String::new(); - if st.font.bold { - css.push_str("font-weight:bold;"); - } - if st.font.italic { - css.push_str("font-style:italic;"); - } - if let Some(h) = st.font.color.hex(&wb.theme) { - let _ = write!(css, "color:{h};"); - } - if st.fill.pattern != gridcraft_model::PatternType::None - && let Some(h) = st.fill.fg.hex(&wb.theme) - { - let _ = write!(css, "background:{h};"); - } - let v = sh.value(c); - let align = match st.align.h { - gridcraft_model::HAlign::Center => "center", - gridcraft_model::HAlign::Right => "right", - gridcraft_model::HAlign::Left => "left", - _ if v.is_number() => "right", - _ => "left", - }; - let _ = write!(css, "text-align:{align};"); + let css = html_cell_css(wb, sh, c); let span = sh .merges .iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cf6ec5..b6caa09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -110,6 +110,110 @@ fn undo_redo() { assert!(s.doc().unwrap().is_dirty()); } +#[test] +fn shape_text_edit_undo_redo_and_noop() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"at": "C4", "width": 210, "height": 90, "text": "Original"})).unwrap()["shape"].clone(); + let original = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + let mut changed = original.clone(); + changed.text = "First line\n\nSecond line & ".into(); + let undo_len = s.doc().unwrap().undo.len(); + s.execute("shape.setText", json!({"id": id, "text": changed.text})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); + assert_eq!(s.doc().unwrap().undo.len(), undo_len + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], original); + + let before_noop = s.doc().unwrap().wb.clone(); + let revision = s.doc().unwrap().revision; + s.execute("shape.setText", json!({"id": id, "text": "Original"})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before_noop)); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + assert_eq!(s.doc().unwrap().redo.len(), 1); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); + + s.execute("shape.setText", json!({"id": id, "text": ""})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text, ""); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0], changed); +} + +#[test] +fn shape_text_rejects_invalid_targets_and_params() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + let rectangle = s.execute("insert.shape", json!({"kind": "rectangle", "text": "Rectangle"})).unwrap()["shape"].clone(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + for params in [ + json!({}), + json!({"id": id}), + json!({"id": id, "text": 12}), + json!({"id": "1", "text": "bad id"}), + json!({"id": -1, "text": "bad id"}), + json!({"id": 1.5, "text": "bad id"}), + json!({"id": u64::from(u32::MAX) + 1, "text": "bad id"}), + json!({"id": 999, "text": "unknown"}), + json!({"id": rectangle, "text": "not a text box"}), + ] { + assert!(matches!(s.execute("shape.setText", params), Err(crate::EngineError::BadParams { .. }))); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + } + s.execute("home.insertSheet", json!({})).unwrap(); + assert!(matches!(s.execute("shape.setText", json!({"id": id, "text": "wrong sheet"})), Err(crate::EngineError::BadParams { .. }))); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().shapes[0].text, "Original"); + assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty()); +} + +#[test] +fn shape_text_respects_sheet_protection() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + assert!(s.execute("shape.setText", json!({"id": id, "text": "Changed"})).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("shape.setText", json!({"id": id, "text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text, "Changed"); +} + +#[test] +fn shape_text_is_capped_like_a_cell() { + let mut s = s(); + let id = s.execute("insert.textBox", json!({"text": "Original"})).unwrap()["shape"].clone(); + // The cap counts characters, not bytes. + let longest = "é".repeat(32_767); + s.execute("shape.setText", json!({"id": id, "text": longest})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text.chars().count(), 32_767); + let undo_len = s.doc().unwrap().undo.len(); + let too_long = "x".repeat(32_768); + assert!(matches!(s.execute("shape.setText", json!({"id": id, "text": too_long})), Err(crate::EngineError::BadParams { .. }))); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[0].text.chars().count(), 32_767); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); +} + +#[test] +fn shape_text_edit_survives_xlsx_roundtrip() { + let mut s = s(); + s.execute("insert.textBox", json!({"text": "Imported"})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "text-box.xlsx", "base64": saved["base64"]})).unwrap(); + let id = s.doc().unwrap().wb.active().unwrap().shapes[0].id; + let text = "Edited & \n\nLast line\n"; + s.execute("shape.setText", json!({"id": id, "text": text})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "edited.xlsx", "base64": saved["base64"]})).unwrap(); + let shape = &s.doc().unwrap().wb.active().unwrap().shapes[0]; + assert_eq!(shape.kind, gridcraft_model::ShapeKind::TextBox); + assert_eq!(shape.text, text); +} + #[test] fn no_border_clears_only_shared_edges_and_undo_restores_them() { use gridcraft_core::RangeRef; @@ -202,6 +306,103 @@ fn copy_paste_shifts_formulas() { assert_eq!(f["formula"], "=E5+B1"); } +#[test] +fn rich_clipboard_copies_selected_displayed_cells_and_styles() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "outside selection"})).unwrap(); + s.execute("range.setValues", json!({"range": "B2", "values": [[0.125, "&\"\nnext"], [0.25, "last"]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0%"})).unwrap(); + s.execute("home.bold", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.fontColor", json!({"range": "B2", "color": "#123456"})).unwrap(); + s.execute("home.fillColor", json!({"range": "B2", "color": "#FEDCBA"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C3", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("copy publishes HTML as well as plain text"); + assert_eq!(copied["text"], "12.5%\t\"&\"\"\nnext\"\n25.0%\tlast\n"); + assert!(html.starts_with("")); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches(""))); + } +} + +#[test] +fn rich_clipboard_clips_merges_and_skips_hidden_rows() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "merged"})).unwrap(); + s.execute("home.mergeCenter", json!({"range": "B2:D5"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("cell.set", json!({"cell": "E4", "input": "side"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C4", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("merged copy publishes HTML"); + assert_eq!(copied["text"], "merged\t\n\t\n"); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); + + // A selection beginning inside a merge must keep its shape without copying + // the original anchor's value from outside the selected rectangle. + let copied = s.execute("edit.copy", json!({"range": "C3:E5", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("partially selected merge publishes HTML"); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); +} + +#[test] +fn rich_clipboard_omits_html_for_large_ranges_without_truncating_text() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A10001", "input": "last cell"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1:A10001", "html": true})).unwrap(); + assert!(copied.get("html").is_none(), "oversized HTML must be omitted, not partially copied"); + let text = copied["text"].as_str().unwrap(); + assert_eq!(text.lines().count(), 10_001); + assert!(text.ends_with("last cell\n")); + let copied = s.execute("edit.copy", json!({"range": "A10001", "html": true})).unwrap(); + assert!(copied["html"].as_str().is_some_and(|html| html.contains("last cell"))); +} + +#[test] +fn rich_clipboard_omits_html_when_escaping_exceeds_byte_budget() { + let mut s = s(); + let text = "&".repeat(1_000_000); + s.execute("range.setValues", json!({"range": "A1", "values": [[text]]})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1", "html": true})).unwrap(); + assert!(copied.get("html").is_none(), "an oversized escaped value must not produce partial HTML"); + assert_eq!(copied["text"].as_str().unwrap().len(), 1_000_001); + assert!(copied["text"].as_str().unwrap().ends_with("&\n")); +} + #[test] fn insert_delete_rows_adjust() { let mut s = s(); @@ -574,6 +775,77 @@ fn xlsx_roundtrip_through_engine() { assert_eq!(v(&s, "B3"), Value::Number(3.0)); } +fn ods_fixture() -> Vec { + use std::io::{Cursor, Write}; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [ + ("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), + ( + "META-INF/manifest.xml", + r#""#, + ), + ( + "content.xml", + r#"Original data"#, + ), + ] { + zip.start_file(name, options).unwrap(); + zip.write_all(data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn ods_import_keeps_cached_values_and_reports_limits() { + let bytes = ods_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods); + for name in ["source.ods", "source.xlsx"] { + let mut s = s(); + let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(!r["warnings"].as_array().unwrap().is_empty()); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_)))); + assert!(s.doc().unwrap().path.is_none()); + assert!(s.doc().unwrap().display_title().ends_with(".xlsx")); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + assert_eq!(v(&s, "B1"), Value::from("Original data")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none()); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn ods_import_never_reuses_source_as_save_target() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let bytes = ods_fixture(); + for name in ["source.ods", "disguised.xlsx"] { + let path = dir.join(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.take_ui_requests(); + s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs"))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert!(s.execute("file.saveAs", json!({"path": dir.join("source.ods")})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "ods"})).is_err()); + let output = dir.join("converted.xlsx"); + s.execute("file.saveAs", json!({"path": output})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(43.0)); + } + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { @@ -582,6 +854,71 @@ fn samples_build() { } } +/// Original minimal BIFF12 records, built in source rather than a vendor workbook fixture. +fn xlsb_fixture() -> Vec { + use std::io::{Cursor, Write}; + fn wide(s: &str) -> Vec { + let units: Vec<_> = s.encode_utf16().collect(); + (units.len() as u32).to_le_bytes().into_iter().chain(units.into_iter().flat_map(u16::to_le_bytes)).collect() + } + fn record(out: &mut Vec, id: u16, payload: &[u8]) { + if id < 128 { + out.push(id as u8); + } else { + out.extend([(id as u8 & 127) | 128, (id >> 7) as u8]); + } + let mut size = payload.len(); + while size >= 128 { + out.push((size as u8 & 127) | 128); + size >>= 7; + } + out.push(size as u8); + out.extend(payload); + } + let mut book = vec![]; + record(&mut book, 131, &[]); // BeginBook + record(&mut book, 143, &[]); // BeginBundleShs + let mut bundle = vec![0; 4]; // Visible + bundle.extend(1u32.to_le_bytes()); + bundle.extend(wide("rId1")); + bundle.extend(wide("Imported")); + record(&mut book, 156, &bundle); + record(&mut book, 144, &[]); + record(&mut book, 132, &[]); + let mut sheet = vec![]; + record(&mut sheet, 129, &[]); // BeginSheet + record(&mut sheet, 145, &[]); // BeginSheetData + record(&mut sheet, 0, &[0; 17]); // Row 0, no spans + let mut numeric = vec![0; 8]; // A1, default style + numeric.extend(42.0f64.to_le_bytes()); + numeric.extend([0; 2]); // Formula flags + numeric.extend(3u32.to_le_bytes()); + numeric.extend([0x1e, 42, 0]); // PtgInt(42) + numeric.extend(0u32.to_le_bytes()); + record(&mut sheet, 9, &numeric); // FmlaNum + let mut text = 1u32.to_le_bytes().to_vec(); // B1 + text.extend(0u32.to_le_bytes()); + text.extend(wide("Original data")); + record(&mut sheet, 6, &text); + record(&mut sheet, 146, &[]); + record(&mut sheet, 130, &[]); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let types = br#""#; + let rels = br#""#; + let book_rels = br#""#; + for (name, data) in [ + ("[Content_Types].xml", types.as_slice()), + ("_rels/.rels", rels.as_slice()), + ("xl/_rels/workbook.bin.rels", book_rels.as_slice()), + ("xl/workbook.bin", book.as_slice()), + ("xl/worksheets/sheet1.bin", sheet.as_slice()), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap().into_inner() +} + #[test] fn every_command_survives_empty_params() { let mut s = Session::new(); @@ -619,3 +956,65 @@ fn ink_strokes_and_ink_to_shape() { assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line"); assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err()); } + +#[test] +fn xlsb_import_keeps_cached_values_and_reports_limits() { + let bytes = xlsb_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsb); + for name in ["source.xlsb", "source.xlsx"] { + let mut s = s(); + let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(!r["warnings"].as_array().unwrap().is_empty()); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_)))); + assert!(s.doc().unwrap().path.is_none()); + assert!(s.doc().unwrap().display_title().ends_with(".xlsx")); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + assert_eq!(v(&s, "B1"), Value::from("Original data")); + assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none()); + s.execute("cell.set", json!({"cell": "B1", "input": "Changed"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); // Imported formula caches are constants. + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn xlsb_import_never_reuses_source_as_save_target() { + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let bytes = xlsb_fixture(); + for name in ["source.xlsb", "disguised.xlsx"] { + let path = dir.join(name); + std::fs::write(&path, &bytes).unwrap(); + let mut s = s(); + s.execute("file.open", json!({"path": path})).unwrap(); + s.take_ui_requests(); + s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs"))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert!(s.execute("file.saveAs", json!({"path": dir.join("source.xlsb")})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "xlsb"})).is_err()); + let output = dir.join("converted.xlsx"); + s.execute("file.saveAs", json!({"path": output})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(43.0)); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn malformed_xlsb_keeps_the_current_workbook() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "Keep me"})).unwrap(); + assert!(s.execute("file.open", json!({"name": "bad.xlsb", "base64": crate::io::base64_encode(b"not a workbook")})).is_err()); + assert_eq!(s.documents().len(), 1); + assert_eq!(v(&s, "A1"), Value::from("Keep me")); + assert!(s.doc().unwrap().is_dirty()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0fb6ecd..d5d5c36 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.", + "Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 082be11..adeeddb 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -180,7 +180,7 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh { mesh } -fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { +pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { let x = geo.x(sh, a.cell.col) + a.dx * geo.z; let y = geo.y(sh, a.cell.row) + a.dy * geo.z; Rect::from_min_size(pos2(x, y), vec2(a.width * geo.z, a.height.max(1.0) * geo.z)) @@ -243,7 +243,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } - if !sp.text.is_empty() { + if !sp.text.is_empty() && !app.editing_text_box(sp.id) { let job = egui::text::LayoutJob::simple( sp.text.clone(), FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), @@ -254,6 +254,9 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 }; p.galley(pos, g, Color32::BLACK); } + if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { + selection_frame(p, r); + } } for im in &sh.images { let r = anchor_rect(geo, sh, &im.anchor); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7753a48..37ec0c8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -188,10 +188,10 @@ impl Dialog { label: "Paste", options: vec![ ("all", "All"), - ("formulas", "Formulas"), - ("values", "Values"), - ("formats", "Formats"), - ("comments", "Comments and notes"), + ("formulas", "Formulas (F)"), + ("values", "Values (V)"), + ("formats", "Formats (T)"), + ("comments", "Comments and notes (C)"), ("validation", "Validation"), ("allExceptBorders", "All except borders"), ("columnWidths", "Column widths"), @@ -205,7 +205,7 @@ impl Dialog { options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")], }, Check { key: "skipBlanks", label: "Skip blanks" }, - Check { key: "transpose", label: "Transpose" }, + Check { key: "transpose", label: "Transpose (E)" }, Check { key: "link", label: "Paste Link" }, ], json!({"what": "all", "operation": "none"}), @@ -632,6 +632,9 @@ fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; + if d.name == "pasteSpecial" { + paste_special_shortcuts(&mut d, ctx); + } let mut open = true; let mut win_open = true; let mut confirm = false; @@ -828,6 +831,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + let right_alt = ctx.input(|i| { + i.key_down(Key::AltRight) + || i.events.iter().any(|event| { + matches!(event, egui::Event::Key { key: Key::AltRight, .. }) + || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) + }) + }); + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); + let mnemonic = |key| match key { + Key::T => Some('t'), + Key::V => Some('v'), + Key::F => Some('f'), + Key::C => Some('c'), + Key::E => Some('e'), + _ => None, + }; + let mut text_to_consume = Vec::new(); + for event in ctx.input(|i| i.events.clone()) { + let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; + if !allowed(modifiers) { + continue; + } + let Some(letter) = mnemonic(key) else { continue }; + text_to_consume.push(letter); + if repeat { + continue; + } + if key == Key::E { + let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); + d.values.insert("transpose".into(), json!(!transpose)); + } else { + let what = match key { + Key::T => "formats", + Key::V => "values", + Key::F => "formulas", + _ => "comments", + }; + d.values.insert("what".into(), json!(what)); + } + } + // Browsers can deliver Text before or after Key. Act only on Key, then remove + // its matching text so one physical press cannot toggle Transpose twice. + ctx.input_mut(|i| { + i.events.retain(|event| match event { + egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), + egui::Event::Text(text) if text.len() == 1 => { + let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); + if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { + text_to_consume.remove(index); + false + } else { + true + } + } + _ => true, + }); + }); +} + fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 9fa1ca1..4bd50dd 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -324,7 +324,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let mut view = app.view(); // Scrolling. let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); - if resp.hovered() || resp.contains_pointer() { + if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); let _ = app.session.run("view.zoom", json!({"percent": z})); @@ -418,6 +418,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Interaction. let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); interact(app, ui, &resp, &geo, sh, &wb); + crate::text_box::show(app, ui, &geo, sh); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); } @@ -1040,6 +1041,10 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel // ------------------------------------------------------------------ interaction fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Text selection and editing must not also move the object or edit worksheet cells. + if app.text_box_editor.is_some() { + return; + } let ctx = ui.ctx().clone(); // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); @@ -1127,6 +1132,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { app.selected_chart = Some(id); + if kind == "shape" + && resp.double_clicked() + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + { + app.begin_text_box_edit(id); + return; + } if kind == "chart" && resp.double_clicked() { app.grid.pane = Some("formatChart".into()); } @@ -1611,12 +1623,13 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { /// Grid keyboard handling when no text field has focus. fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar")]; - if app.editor.is_none() && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { + let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let editing = app.editor.is_some() || app.text_box_editor.is_some(); + if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { resp.request_focus(); } - let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && !(app.editor.is_none() && stale.contains(&f))); - if app.editor.is_some() || app.dialog.is_some() || other_focus || app.message.is_some() { + let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); + if editing || app.dialog.is_some() || other_focus || app.message.is_some() { return; } if ctx.memory(|m| m.focused()).is_none() { @@ -1631,18 +1644,10 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: for ev in events { match ev { egui::Event::Copy => { - if let Ok(r) = app.run("edit.copy", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.copy"); } egui::Event::Cut => { - if let Ok(r) = app.run("edit.cut", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})), egui::Event::Text(text) => { @@ -1903,11 +1908,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } @@ -2134,11 +2135,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs new file mode 100644 index 0000000..6fc620a --- /dev/null +++ b/crates/ui-egui/src/keytips.rs @@ -0,0 +1,208 @@ +//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions. + +use egui::{Context, Event, Key, Modifiers, os::OperatingSystem}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Home, + Command(&'static str), + Dialog(&'static str), +} + +#[derive(Default)] +pub struct KeyTips { + prefix: Option, + alt_armed: bool, + // Keep consuming a held mnemonic after its action closes keytips. + held: Vec, +} + +impl KeyTips { + pub fn prefix(&self) -> Option<&str> { + self.prefix.as_deref() + } + + pub fn clear(&mut self) { + self.prefix = None; + self.alt_armed = false; + self.held.clear(); + } + + fn toggle(&mut self) { + self.prefix = if self.prefix.is_some() { None } else { Some(String::new()) }; + self.alt_armed = false; + } + + /// Remove only handled events before any text editors or the grid see them. + pub fn process(&mut self, ctx: &Context, enabled: bool) -> Vec { + if !enabled || !ctx.input(|i| i.focused) { + self.clear(); + return Vec::new(); + } + let mac = ctx.os() == OperatingSystem::Mac; + let events = ctx.input_mut(|i| std::mem::take(&mut i.events)); + let pairs = text_pairs(&events); + let mut consumed = std::collections::HashSet::new(); + let mut actions = Vec::new(); + for (index, event) in events.iter().enumerate() { + let mut handled = false; + match event { + Event::PointerButton { pressed: true, .. } | Event::MouseWheel { .. } | Event::WindowFocused(false) | Event::Ime(_) => self.clear(), + Event::Copy | Event::Cut | Event::Paste(_) => self.clear(), + Event::ModifiersChanged(m) => { + if command_modifier(*m) || (mac && m.alt) { + self.clear(); + } else if m.shift { + self.alt_armed = false; + } + } + Event::Key { key: Key::AltLeft, pressed, repeat, modifiers, .. } if !mac && !command_modifier(*modifiers) && !modifiers.shift => { + if *pressed { + if !repeat { + self.alt_armed = true; + } + handled = true; + } else if self.alt_armed { + self.toggle(); + handled = true; + } + } + Event::Key { key, pressed, repeat, modifiers, .. } => { + self.alt_armed = false; + if command_modifier(*modifiers) + || modifiers.alt + || matches!(key, Key::AltLeft | Key::AltRight | Key::ControlLeft | Key::ControlRight | Key::SuperLeft | Key::SuperRight) + { + self.clear(); + } else if !pressed { + handled = self.held.contains(key); + self.held.retain(|held| held != key); + } else if *repeat && (self.prefix.is_some() || self.held.contains(key)) { + handled = true; + } else if *key == Key::F10 && modifiers.is_none() { + self.toggle(); + handled = true; + } else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) { + handled = true; + if *key == Key::Escape { + if let Some(prefix) = self.prefix.as_mut() + && prefix.pop().is_none() + { + self.prefix = None; + } + } else if key.name().len() == 1 { + let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name()); + match next.as_str() { + "H" => { + self.prefix = Some(next); + actions.push(Action::Home); + } + "HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next), + "HAR" => self.finish(Action::Command("home.alignRight"), &mut actions), + "HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions), + "HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions), + "HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions), + "HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions), + _ => {} + } + } + } + if handled && *pressed && !self.held.contains(key) { + self.held.push(*key); + } + } + Event::Text(_) => { + self.alt_armed = false; + // Paired text is decided with its key, which may occur later on web. + handled = self.prefix.is_some() && !pairs.iter().any(|(_, text)| *text == index); + } + _ => {} + } + if handled { + consumed.insert(index); + if let Some((_, text)) = pairs.iter().find(|(key, _)| *key == index) { + consumed.insert(*text); + } + } + } + ctx.input_mut(|i| { + i.events = events.into_iter().enumerate().filter_map(|(index, event)| (!consumed.contains(&index)).then_some(event)).collect() + }); + actions + } + + fn finish(&mut self, action: Action, actions: &mut Vec) { + self.prefix = None; + actions.push(action); + } +} + +fn command_modifier(m: Modifiers) -> bool { + m.ctrl || m.command || m.mac_cmd +} + +/// Native integrations emit Key then Text; browsers can emit Text then Key. +fn text_pairs(events: &[Event]) -> Vec<(usize, usize)> { + let mut pairs = Vec::new(); + for (text_index, event) in events.iter().enumerate() { + let Event::Text(_) = event else { continue }; + let before = events.iter().enumerate().take(text_index).rev().find(|(_, e)| !matches!(e, Event::ModifiersChanged(_))); + let after = events.iter().enumerate().skip(text_index + 1).find(|(_, e)| !matches!(e, Event::ModifiersChanged(_))); + for (key_index, event) in before.into_iter().chain(after) { + if let Event::Key { key, pressed: true, modifiers, .. } = event + && (key.name().len() == 1 || modifiers.alt || command_modifier(*modifiers)) + && !pairs.iter().any(|(paired, _)| *paired == key_index) + { + pairs.push((key_index, text_index)); + break; + } + } + } + pairs +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + #[test] + fn active_mode_preserves_modified_text_before_key() { + for (os, letter, text, modifiers) in [ + (OperatingSystem::Windows, Key::E, "€", Modifiers::CTRL | Modifiers::ALT), + (OperatingSystem::Nix, Key::E, "€", Modifiers::ALT), + (OperatingSystem::Mac, Key::O, "œ", Modifiers::ALT), + (OperatingSystem::Mac, Key::C, "c", Modifiers::COMMAND), + ] { + let ctx = Context::default(); + ctx.set_os(os); + let mut tips = KeyTips { prefix: Some("H".into()), ..Default::default() }; + let events = vec![Event::Text(text.into()), key(letter, true, modifiers)]; + let mut remaining = Vec::new(); + let mut output = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| { + assert!(tips.process(ui.ctx(), true).is_empty()); + remaining = ui.input(|i| i.events.clone()); + }); + output.textures_delta.clear(); + assert_eq!(tips.prefix(), None); + assert_eq!(remaining, events); + } + } + + #[test] + fn shift_between_alt_press_and_release_does_not_activate() { + let ctx = Context::default(); + ctx.set_os(OperatingSystem::Windows); + let mut tips = KeyTips::default(); + let events = + vec![key(Key::AltLeft, true, Modifiers::ALT), key(Key::ShiftLeft, true, Modifiers::NONE), key(Key::AltLeft, false, Modifiers::NONE)]; + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + assert!(tips.process(ui.ctx(), true).is_empty()); + }); + output.textures_delta.clear(); + assert_eq!(tips.prefix(), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2e1d5d8..4588e6c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,10 +16,12 @@ pub mod formula_bar; pub mod grid; pub mod i18n; pub mod icons; +pub mod keytips; pub mod panes; pub mod pivot_pane; pub mod ribbon; pub mod tabs; +pub mod text_box; pub mod theme; pub mod widgets; @@ -103,6 +105,9 @@ pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, pub open_url: Option>, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option Result<(), String>>>, /// Web: deliver a file to the user (name, bytes). pub download: Option>, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. @@ -127,6 +132,7 @@ pub struct SheetApp { pub ui: UiState, pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, + pub text_box_editor: Option, pub dialog: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, @@ -143,6 +149,8 @@ pub struct SheetApp { fonts_han: Option, effective_dark: bool, pub name_box: Option, + /// Transient ribbon keyboard navigation; never saved with UI preferences. + pub keytips: keytips::KeyTips, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). pub selected_chart: Option, @@ -156,6 +164,7 @@ impl SheetApp { ui: UiState::default(), views: HashMap::new(), editor: None, + text_box_editor: None, dialog: None, message: None, toast: None, @@ -174,6 +183,7 @@ impl SheetApp { fonts_han: None, effective_dark: false, name_box: None, + keytips: keytips::KeyTips::default(), shots: control::Shots::default(), selected_chart: None, started: now_ms(), @@ -193,6 +203,29 @@ impl SheetApp { /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { + if id.starts_with("file.") + || matches!( + id, + "window.activate" + | "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "view.newWindow" + | "view.hideWindow" + | "view.unhideWindow" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) + { + self.commit_text_box_edit()?; + } if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } @@ -201,6 +234,23 @@ impl SheetApp { r } + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + // Only ask the engine for HTML when the host can publish it. + let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { if let Err(e) = self.run(id, params) { @@ -295,6 +345,10 @@ impl SheetApp { } pub fn open_dialog(&mut self, name: &str, params: Json) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } match name { "open" => { if let Some(start) = &self.services.open_async { @@ -325,7 +379,7 @@ impl SheetApp { } pub fn open_path(&mut self, path: &str) { - match self.session.run("file.open", json!({"path": path})) { + match self.run("file.open", json!({"path": path})) { Ok(_) => { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); @@ -351,6 +405,10 @@ impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); @@ -460,7 +518,7 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.session.run("file.open", json!({"name": name, "base64": b64})) { + if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) { self.message = Some(("GridCraft".into(), clean_error(&e))); } self.after_engine(); @@ -484,9 +542,11 @@ impl SheetApp { return; } let t0 = now_ms(); + text_box::before_ui(self, &ctx); // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); + self.ribbon_keys(&ctx); ribbon::title_bar(self, ui); ribbon::show(self, ui); if self.ui.formula_bar { @@ -523,6 +583,7 @@ impl SheetApp { // AutoSave: save workbooks that have a file shortly after they change. if self.grid.autosave && self.editor.is_none() + && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { @@ -539,6 +600,43 @@ impl SheetApp { } } + fn ribbon_keys(&mut self, ctx: &egui::Context) { + let before = self.keytips.prefix().map(str::to_string); + let enabled = self.editor.is_none() + && self.dialog.is_none() + && self.message.is_none() + && self.name_box.is_none() + && !ctx.text_edit_focused() + && self.grid.context_menu.is_none() + && self.grid.header_menu.is_none() + && self.grid.filter_menu.is_none() + && self.grid.list_picker.is_none() + && self.grid.renaming_tab.is_none() + && (before.is_some() || !egui::Popup::is_any_open(ctx)); + let actions = self.keytips.process(ctx, enabled); + if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { + // Leave pointer cancellation to the popup so clicking a menu item still works. + egui::Popup::close_all(ctx); + } + // The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when + // the prefix changes, not every frame (that would reset a tab the user picked meanwhile). + let after = self.keytips.prefix(); + if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) { + self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"})); + self.ui.ribbon_collapsed = false; + } + for action in actions { + match action { + keytips::Action::Home => { + self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"})); + self.ui.ribbon_collapsed = false; + } + keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + } + } + } + /// Current selection's top-left cell text as A1 (for the Name Box). pub fn name_box_text(&self) -> String { let Some(d) = self.session.active() else { return String::new() }; @@ -573,6 +671,7 @@ pub fn clean_error(e: &str) -> String { e.to_string() } +#[allow(clippy::disallowed_methods)] // the clock is read only off wasm pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fdb99f0..28d025c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -169,6 +169,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if resp.double_clicked() { app.ui.ribbon_collapsed = !collapsed; } + if tab == "Home" && app.keytips.prefix() == Some("") { + keytip(app, ui, "H", pos2(r.center().x, r.bottom() - 1.0)); + } } }); }); @@ -238,7 +241,7 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { if sh.table_at(d.selection.active).is_some() { v.push("Table Design"); } - if app.selected_chart.is_some() { + if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { v.push("Chart Design"); } } @@ -270,6 +273,18 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { app.run_or_alert(id, params); } +/// Keytips are painted, not focusable widgets: the grid retains keyboard focus. +fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { + let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); + let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); + painter.rect_filled(rect, 2.0, t.input_bg); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); +} + fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { for (label, id, p) in items { if *label == "-" { @@ -277,7 +292,18 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: continue; } let lang = app.ui.language; - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))).clicked() { + let response = ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))); + let sequence = match (app.keytips.prefix(), *id) { + (Some("HV"), "dialog:pasteSpecial") => Some("HVS"), + (Some("E"), "dialog:pasteSpecial") => Some("ES"), + (Some("HO"), "home.autofitColumnWidth") => Some("HOI"), + (Some("OC"), "home.autofitColumnWidth") => Some("OCA"), + _ => None, + }; + if let Some(sequence) = sequence { + keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); + } + if response.clicked() { if let Some(d) = id.strip_prefix("dialog:") { app.open_dialog(d, p.clone()); } else { @@ -292,6 +318,9 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let st = active_style(app); // Clipboard let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); + if app.keytips.prefix() == Some("H") { + keytip(app, ui, "HV", paste.rect.center_bottom()); + } if paste.clicked() { if let Some(text) = app.grid.system_clipboard.take() { act(app, "edit.paste", json!({"text": text})); @@ -299,7 +328,29 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "edit.paste", json!({})); } } - egui::Popup::context_menu(&paste).show(|ui| { + let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); + // Keep a keyboard-opened menu anchored to the button when a pointer click + // cancels keytips, so its items remain in place and can still be clicked. + let anchor_id = paste.id.with("keytip_anchor"); + let anchored = ui.ctx().data_mut(|data| { + let anchored = if paste_keys { + true + } else if paste.secondary_clicked() { + false + } else { + data.get_temp::(anchor_id).unwrap_or(false) + }; + data.insert_temp(anchor_id, anchored); + anchored + }); + let mut paste_popup = egui::Popup::context_menu(&paste); + if anchored { + paste_popup = paste_popup.anchor(&paste); + } + if paste_keys { + paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + paste_popup.show(|ui| { menu_items( app, ui, @@ -316,17 +367,11 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.cut", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.copy", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.copy"); } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); @@ -483,7 +528,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), ] { - if toggle_button(ui, icon, st.align.h == h, tip).clicked() { + let response = toggle_button(ui, icon, st.align.h == h, tip); + if matches!(app.keytips.prefix(), Some("H" | "HA")) { + let sequence = match h { + HAlign::Left => "HAL", + HAlign::Center => "HAC", + _ => "HAR", + }; + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { act(app, id, json!({})); } } @@ -599,7 +653,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let fmt = small_button(ui, Icon::Format, "Format", "Format", true); - egui::Popup::menu(&fmt).show(|ui| { + match app.keytips.prefix() { + Some("H") => keytip(app, ui, "HO", fmt.rect.center_bottom()), + Some("O") => keytip(app, ui, "OC", fmt.rect.center_bottom()), + _ => {} + } + let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); + let mut format_popup = egui::Popup::menu(&fmt); + if format_keys { + format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + format_popup.show(|ui| { menu_items( app, ui, diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs new file mode 100644 index 0000000..1e83f62 --- /dev/null +++ b/crates/ui-egui/src/text_box.rs @@ -0,0 +1,173 @@ +//! In-place editing of a text box. The draft becomes one undoable engine command. + +use egui::{Color32, FontId, Key, Modifiers, Rect, TextEdit}; +use gridcraft_engine::{Mode, model::ShapeKind}; +use serde_json::json; + +use crate::{SheetApp, grid::Geo, theme}; + +#[derive(Clone, Debug)] +pub struct EditState { + pub id: u32, + pub text: String, + uid: u64, + sheet: usize, + original: String, + request_focus: bool, + rect: Option, +} + +pub(crate) fn editor_id() -> egui::Id { + egui::Id::new("gridcraft.text_box_editor") +} + +impl SheetApp { + pub fn begin_text_box_edit(&mut self, id: u32) -> bool { + if !self.commit_edit(0, 0, false, false) { + return false; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), crate::clean_error(&e))); + return false; + } + let Some(d) = self.session.active() else { return false }; + let Some(sh) = d.wb.active() else { return false }; + let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else { + return false; + }; + // shape.setText refuses edits on a protected sheet; don't open an editor that can't commit. + if sh.is_protected() { + self.message = Some(("Text Box".into(), "The cell or chart you're trying to change is on a protected sheet.".into())); + return false; + } + self.text_box_editor = Some(EditState { + id, + text: shape.text.clone(), + uid: d.uid, + sheet: d.wb.active_sheet, + original: shape.text.clone(), + request_focus: true, + rect: None, + }); + self.selected_chart = Some(id); + self.grid.drag = crate::grid::Drag::None; + self.grid.drag_select = false; + self.session.mode = Mode::Edit; + true + } + + pub fn commit_text_box_edit(&mut self) -> Result<(), String> { + let Some(ed) = self.text_box_editor.take() else { return Ok(()) }; + let result = if self.view_key() != Some((ed.uid, ed.sheet)) { + Err("The text box's worksheet is no longer active.".into()) + } else if ed.text == ed.original { + Ok(()) + } else { + // Call the engine directly: app.run flushes this draft before saving/switching. + self.session.run("shape.setText", json!({"id": ed.id, "text": ed.text})).map(|_| ()) + }; + match result { + Ok(()) => { + self.session.mode = Mode::Ready; + self.after_engine(); + Ok(()) + } + Err(e) => { + self.text_box_editor = Some(ed); + Err(e) + } + } + } + + pub fn cancel_text_box_edit(&mut self) { + self.text_box_editor = None; + self.session.mode = Mode::Ready; + } + + pub(crate) fn editing_text_box(&self, id: u32) -> bool { + self.text_box_editor.as_ref().is_some_and(|ed| ed.id == id && self.view_key() == Some((ed.uid, ed.sheet))) + } +} + +/// Resolve outside clicks before the ribbon/tabs can save or switch the workbook. +pub(crate) fn before_ui(app: &mut SheetApp, ctx: &egui::Context) { + let Some(ed) = &app.text_box_editor else { return }; + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + app.cancel_text_box_edit(); + ctx.memory_mut(|m| m.surrender_focus(editor_id())); + return; + } + let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| ed.rect.is_none_or(|r| !r.contains(p)))); + if outside { + match app.commit_text_box_edit() { + Ok(()) => ctx.memory_mut(|m| m.surrender_focus(editor_id())), + Err(e) => app.message = Some(("Text Box".into(), crate::clean_error(&e))), + } + } +} + +pub(crate) fn contains_pointer(app: &SheetApp, ctx: &egui::Context) -> bool { + app.text_box_editor.as_ref().and_then(|ed| ed.rect).is_some_and(|r| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| r.contains(p)))) +} + +pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcraft_engine::model::Sheet) { + let Some(mut ed) = app.text_box_editor.take() else { return }; + if app.view_key() != Some((ed.uid, ed.sheet)) { + app.text_box_editor = Some(ed); + return; + } + let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else { + app.session.mode = Mode::Ready; + return; + }; + let save = ui.input_mut(|i| { + if i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::S) { + Some("file.saveAs") + } else if i.consume_key(Modifiers::COMMAND, Key::S) { + Some("file.save") + } else { + None + } + }); + let rect = crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells); + if !rect.is_positive() { + ed.rect = None; + app.text_box_editor = Some(ed); + if let Some(command) = save { + app.run_or_alert(command, json!({})); + } + return; + } + ed.rect = Some(rect); + let id = editor_id(); + if ed.request_focus { + // A new editing session must not reuse the previous box's caret/undo history. + egui::text_edit::TextEditState::default().store(ui.ctx(), id); + } + let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect)); + child.set_clip_rect(rect); + egui::ScrollArea::vertical().id_salt(id).max_height(rect.height()).auto_shrink([false, false]).show(&mut child, |ui| { + let response = ui.add( + TextEdit::multiline(&mut ed.text) + .id(id) + .font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into()))) + .text_color(Color32::BLACK) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(rect.width()) + .desired_rows(1) + // shape.setText's limit (a cell's). + .char_limit(32_767) + .min_size(rect.size()) + .lock_focus(true), + ); + if ed.request_focus { + response.request_focus(); + ed.request_focus = false; + } + }); + app.text_box_editor = Some(ed); + if let Some(command) = save { + app.run_or_alert(command, json!({})); + } +} diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs new file mode 100644 index 0000000..188dbdc --- /dev/null +++ b/crates/ui-egui/tests/clipboard.rs @@ -0,0 +1,85 @@ +//! Copy/cut route rich tables to the host without a later egui text write replacing them. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, OutputCommand}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["Ada", 42]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +#[test] +fn copy_and_cut_publish_html_and_text_without_overwriting_rich_clipboard() { + for event in [Event::Copy, Event::Cut] { + let calls = Rc::new(RefCell::new(Vec::new())); + let received = calls.clone(); + let mut h = harness(Services { + copy_html: Some(Box::new(move |html, text| { + received.borrow_mut().push((html.to_string(), text.to_string())); + Ok(()) + })), + ..Default::default() + }); + h.input_mut().events.push(event.clone()); + h.step(); + let calls = calls.borrow(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains("; + +fn harness(session: Session) -> Harness { + harness_on(session, OperatingSystem::Nix) +} + +fn harness_on(session: Session, os: OperatingSystem) -> Harness { + let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn events(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]); +} + +fn letter(h: &mut Harness, key: Key, text_first: bool) { + let text = Event::Text(key.name().to_ascii_lowercase()); + let pressed = key_event(key, true, Modifiers::NONE); + let pair = if text_first { [text, pressed] } else { [pressed, text] }; + events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)])); +} + +fn sequence(h: &mut Harness, letters: &[Key], text_first: bool) { + key(h, Key::F10); + for &k in letters { + letter(h, k, text_first); + } +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn alignment(h: &Harness) -> HAlign { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).align.h +} + +fn copied_row() -> Session { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "=20+22"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "99"})).unwrap(); + s.execute("edit.copy", json!({"range": "A1:B1"})).unwrap(); + s.execute("selection.set", json!({"cell": "D4"})).unwrap(); + s +} + +#[test] +fn alignment_sequences_consume_key_and_text_events_in_either_order() { + for text_first in [false, true] { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap(); + let mut h = harness(s); + h.state_mut().ui.ribbon_tab = "Insert".into(); + for (k, expected) in [(Key::R, HAlign::Right), (Key::L, HAlign::Left), (Key::C, HAlign::Center)] { + sequence(&mut h, &[Key::H, Key::A, k], text_first); + assert_eq!(h.state().ui.ribbon_tab, "Home"); + assert_eq!(alignment(&h), expected); + assert_eq!(value(&h, "A1"), Value::from("keep me")); + assert!(h.state().editor.is_none(), "access-key letters must not begin a cell edit"); + assert_eq!(h.state().keytips.prefix(), None); + } + // Consuming the terminal access key must not swallow the next ordinary keystroke. + letter(&mut h, Key::X, text_first); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("x")); + } +} + +#[test] +fn paste_special_options_wait_for_enter_and_transpose_values() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::V, Key::S], true); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + for (k, what) in [(Key::T, "formats"), (Key::V, "values"), (Key::F, "formulas"), (Key::C, "comments")] { + letter(&mut h, k, true); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("what"), Some(&json!(what))); + assert_eq!(value(&h, "D4"), Value::Empty, "choosing a paste option must not paste yet"); + } + letter(&mut h, Key::V, true); + letter(&mut h, Key::E, true); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(true))); + for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] { + events( + &mut h, + [ + Event::ModifiersChanged(modifiers), + key_event(Key::T, true, modifiers), + key_event(Key::T, false, modifiers), + key_event(Key::E, true, modifiers), + key_event(Key::E, false, modifiers), + Event::ModifiersChanged(Modifiers::NONE), + ], + ); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values.get("what"), Some(&json!("values")), "Ctrl/Cmd must not select a paste mnemonic"); + assert_eq!(dialog.values.get("transpose"), Some(&json!(true)), "Ctrl/Cmd must not toggle transpose"); + } + // Non-macOS Alt+E selects the dialog mnemonic without activating ribbon access keys. + events( + &mut h, + [ + Event::ModifiersChanged(Modifiers::ALT), + Event::Text("e".into()), + key_event(Key::E, true, Modifiers::ALT), + key_event(Key::E, false, Modifiers::ALT), + Event::ModifiersChanged(Modifiers::NONE), + ], + ); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(false))); + assert_eq!(h.state().keytips.prefix(), None); + letter(&mut h, Key::E, false); + key(&mut h, Key::Enter); + assert!(h.state().dialog.is_none()); + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "D4"), Value::Number(42.0)); + assert_eq!(value(&h, "D5"), Value::Number(99.0)); + assert_eq!(value(&h, "E4"), Value::Empty); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.is_none()); +} + +#[test] +fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::V], false); + assert_eq!(h.state().keytips.prefix(), Some("HV")); + assert!(h.state().dialog.is_none()); + + // This sends actual pointer events at the accessible menu item's rectangle. + // The press cancels keytips, but must not relocate or close the menu before the click. + h.get_by_label("Paste Special…").click(); + h.run_steps(3); + + assert_eq!(h.state().keytips.prefix(), None); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + assert_eq!(value(&h, "D4"), Value::Empty); + assert!(h.state().editor.is_none()); +} + +#[test] +fn legacy_paste_special_keeps_formulas_when_requested() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::E, Key::S], false); + assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial"); + letter(&mut h, Key::F, false); + assert_eq!(value(&h, "D4"), Value::Empty); + key(&mut h, Key::Enter); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.as_ref().unwrap().text, "20+22"); + assert_eq!(value(&h, "D4"), Value::Number(42.0)); + assert_eq!(value(&h, "E4"), Value::Number(99.0)); + assert!(h.state().editor.is_none()); + key(&mut h, Key::ArrowDown); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D5"); +} + +#[test] +fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() { + for route in [&[Key::H, Key::O, Key::I][..], &[Key::O, Key::C, Key::A][..]] { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "A long heading that needs a wider column"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:A", "width": 12})).unwrap(); + let mut h = harness(s); + let before = h.state().session.active().unwrap().wb.active().unwrap().col_width(0); + sequence(&mut h, route, false); + let after = h.state().session.active().unwrap().wb.active().unwrap().col_width(0); + assert!(after > before, "autofit must widen the narrow column"); + assert_eq!(value(&h, "A1"), Value::from("A long heading that needs a wider column")); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().keytips.prefix(), None); + letter(&mut h, Key::X, false); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("x")); + } +} + +#[test] +fn legacy_prefix_switches_to_home_once_not_every_frame() { + let mut h = harness(blank()); + h.state_mut().ui.ribbon_tab = "Insert".into(); + sequence(&mut h, &[Key::E], false); + assert_eq!(h.state().keytips.prefix(), Some("E")); + assert_eq!(h.state().ui.ribbon_tab, "Home"); + // While the prefix stays the same, later frames must not keep forcing the Home tab. + h.state_mut().ui.ribbon_tab = "Insert".into(); + h.run_steps(3); + assert_eq!(h.state().keytips.prefix(), Some("E")); + assert_eq!(h.state().ui.ribbon_tab, "Insert"); +} + +#[test] +fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() { + let mut h = harness(copied_row()); + sequence(&mut h, &[Key::H, Key::A], false); + assert_eq!(h.state().keytips.prefix(), Some("HA")); + for expected in [Some("H"), Some(""), None] { + key(&mut h, Key::Escape); + assert_eq!(h.state().keytips.prefix(), expected); + assert_eq!(h.state().session.clipboard.as_ref().unwrap().range.a1(), "A1:B1"); + } + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), Some("")); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), None); + assert!(h.state().session.clipboard.is_some()); + + // A bare left Alt is the desktop entry point on non-macOS systems. + events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]); + events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE)]); + assert_eq!(h.state().keytips.prefix(), Some("")); +} + +#[test] +fn mouse_press_and_window_focus_loss_cancel_keytips() { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H], false); + let pos = h.state().grid.cells_rect.unwrap().center(); + events( + &mut h, + [ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ], + ); + assert_eq!(h.state().keytips.prefix(), None); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), Some("")); + h.input_mut().focused = false; + events(&mut h, [Event::WindowFocused(false)]); + assert_eq!(h.state().keytips.prefix(), None); + assert!(h.state().editor.is_none()); +} + +#[test] +fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() { + let mut h = harness(blank()); + // egui derives repeats from held-key state, so send presses across frames before release. + events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]); + events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]); + assert_eq!(h.state().keytips.prefix(), Some("")); + events(&mut h, [key_event(Key::F10, false, Modifiers::NONE)]); + events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]); + events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]); + assert_eq!(h.state().keytips.prefix(), Some("H")); + events(&mut h, [key_event(Key::H, false, Modifiers::NONE)]); + letter(&mut h, Key::A, false); + events( + &mut h, + [ + key_event(Key::R, true, Modifiers::NONE), + Event::Text("r".into()), + key_event(Key::R, true, Modifiers::NONE), + Event::Text("r".into()), + key_event(Key::R, false, Modifiers::NONE), + ], + ); + assert_eq!(alignment(&h), HAlign::Right); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.alignRight").count(), 1); + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "A1"), Value::Empty); +} + +#[test] +fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() { + let mut h = harness(blank()); + events(&mut h, [Event::Text("hello".into())]); + key(&mut h, Key::F10); + assert_eq!(h.state().keytips.prefix(), None, "F10 must not interrupt the cell editor"); + assert_eq!(h.state().editor.as_ref().unwrap().text, "hello"); + events(&mut h, [Event::Text("!".into())]); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("hello!")); + + let mut h = harness(blank()); + let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE }; + events(&mut h, [Event::ModifiersChanged(altgr), key_event(Key::AltRight, true, altgr), key_event(Key::Q, true, altgr), Event::Text("@".into())]); + assert_eq!(h.state().keytips.prefix(), None); + assert_eq!(h.state().editor.as_ref().unwrap().text, "@"); + + let mut h = harness_on(blank(), OperatingSystem::Mac); + events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]); + events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE), Event::Text("é".into())]); + assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition"); + assert_eq!(h.state().editor.as_ref().unwrap().text, "é"); +} diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/text_box_editing.rs new file mode 100644 index 0000000..1d3b053 --- /dev/null +++ b/crates/ui-egui/tests/text_box_editing.rs @@ -0,0 +1,241 @@ +//! Text-box editing through real pointer and keyboard input, including document boundaries. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{Shape, Workbook}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const ORIGINAL: &str = "First line\nSecond line"; + +fn at(s: &str) -> CellRef { + CellRef::parse(s).unwrap() +} + +fn fixture() -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "B2", "input": "cell stays"})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let id = session.execute("insert.textBox", json!({"at": "B2", "width": 240, "height": 120, "text": ORIGINAL})).unwrap()["shape"].as_u64().unwrap() + as u32; + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 800.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + (h, id) +} + +fn shape(h: &Harness, id: u32) -> &Shape { + h.state().session.doc().unwrap().wb.active().unwrap().shapes.iter().find(|s| s.id == id).unwrap() +} + +fn shape_text(wb: &Workbook, sheet: usize, id: u32) -> &str { + &wb.sheet(sheet).unwrap().shapes.iter().find(|s| s.id == id).unwrap().text +} + +fn shape_rect(h: &Harness, id: u32) -> Rect { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let a = shape(h, id).anchor; + Rect::from_min_size(pos2(geo.x(sh, a.cell.col) + a.dx * geo.z, geo.y(sh, a.cell.row) + a.dy * geo.z), vec2(a.width, a.height) * geo.z) +} + +fn cell_center(h: &Harness, address: &str) -> Pos2 { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, at(address)).center() +} + +fn pointer(h: &mut Harness, p: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +fn click(h: &mut Harness, p: Pos2) { + pointer(h, p, true); + pointer(h, p, false); + h.run_steps(2); +} + +fn edit_box(h: &mut Harness, id: u32) { + // Separate this gesture from egui's 600 ms triple-click window as well. + h.run_steps(45); + let p = shape_rect(h, id).center(); + click(h, p); + click(h, p); +} + +fn key(h: &mut Harness, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn event(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(2); +} + +fn replace(h: &mut Harness, text: &str) { + key(h, Key::A, Modifiers::COMMAND); + event(h, Event::Paste(text.into())); +} + +fn assert_cell_unchanged(h: &Harness) { + assert_eq!(h.state().session.doc().unwrap().wb.active().unwrap().value(at("B2")), Value::text("cell stays")); + assert!(h.state().editor.is_none(), "text-box input must not start a cell editor"); +} + +#[test] +fn double_click_edits_multiline_text_and_commits_on_outside_click() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + assert_eq!(h.state().selected_chart, Some(id)); + let anchor = shape(&h, id).anchor; + let start = shape_rect(&h, id).min + vec2(5.0, 10.0); + drag(&mut h, start, start + vec2(60.0, 0.0)); + assert_eq!(shape(&h, id).anchor, anchor, "dragging inside the editor selects text instead of moving the box"); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if !text.is_empty()))); + key(&mut h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Copy); + h.step(); + assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == ORIGINAL))); + event(&mut h, Event::Cut); + event(&mut h, Event::Paste("New first line".into())); + key(&mut h, Key::Enter, Modifiers::NONE); + event(&mut h, Event::Text("New second line".into())); + assert_cell_unchanged(&h); + assert_eq!(h.state().session.doc().unwrap().selection.active, at("B2")); + + let outside = cell_center(&h, "J15"); + click(&mut h, outside); + assert_eq!(shape(&h, id).text, "New first line\nNew second line"); + assert_cell_unchanged(&h); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + pointer(h, from, true); + // Cross egui's drag threshold while still inside the object/resize handle. + h.input_mut().events.push(Event::PointerMoved(from + vec2(5.0, 5.0))); + h.step(); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + pointer(h, to, false); + h.run_steps(2); +} + +#[test] +fn escape_discards_text_and_the_box_still_moves_and_resizes() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + replace(&mut h, "discard this edit"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); + h.run_steps(30); + + let before = shape_rect(&h, id); + drag(&mut h, before.center(), before.center() + vec2(40.0, 30.0)); + let moved = shape_rect(&h, id); + assert!(moved.left() > before.left() && moved.top() > before.top(), "ordinary dragging still moves the box"); + assert!((moved.size() - before.size()).abs().max_elem() < 0.1); + + let handle = moved.right_bottom() - vec2(7.0, 7.0); + drag(&mut h, handle, handle + vec2(45.0, 25.0)); + let resized = shape_rect(&h, id); + assert!(resized.width() > moved.width() && resized.height() > moved.height(), "the corner still resizes the box"); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); + + edit_box(&mut h, id); + replace(&mut h, "Offscreen draft"); + h.state_mut().view_mut().unwrap().scroll = vec2(900.0, 700.0); + h.run_steps(2); + assert_eq!(h.state().text_box_editor.as_ref().unwrap().text, "Offscreen draft"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); +} + +#[test] +fn saving_and_switching_context_commit_to_the_original_text_box() { + let (mut h, id) = fixture(); + edit_box(&mut h, id); + replace(&mut h, "Saved draft\nSecond paragraph"); + h.state_mut().run("sheet.read", json!({})).unwrap(); + assert_eq!(shape(&h, id).text, ORIGINAL, "inspecting the sheet must not commit a draft"); + assert!(h.state().text_box_editor.is_some()); + let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let bytes = gridcraft_engine::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(); + let (reopened, _) = gridcraft_engine::io::open_bytes("edited.xlsx", &bytes).unwrap(); + assert_eq!(shape_text(&reopened, 0, id), "Saved draft\nSecond paragraph"); + assert_eq!(reopened.sheet(0).unwrap().value(at("B2")), Value::text("cell stays")); + + // Save and Save As must work while the editor still owns keyboard focus. + let downloaded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = downloaded.clone(); + h.state_mut().services.download = Some(Box::new(move |_, bytes| *sink.lock().unwrap() = bytes.to_vec())); + for modifiers in [Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT] { + edit_box(&mut h, id); + replace(&mut h, "Saved from the text editor"); + key(&mut h, Key::S, modifiers); + let (saved, _) = gridcraft_engine::io::open_bytes("shortcut.xlsx", &downloaded.lock().unwrap()).unwrap(); + assert_eq!(shape_text(&saved, 0, id), "Saved from the text editor"); + downloaded.lock().unwrap().clear(); + } + + h.state_mut().run("home.insertSheet", json!({"name": "Other"})).unwrap(); + let other_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other sheet"})).unwrap()["shape"].as_u64().unwrap() as u32; + h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + edit_box(&mut h, id); + replace(&mut h, "Original sheet only"); + h.state_mut().run("sheet.activate", json!({"sheet": 1})).unwrap(); + let wb = &h.state().session.doc().unwrap().wb; + assert_eq!(shape_text(wb, 0, id), "Original sheet only"); + assert_eq!(shape_text(wb, 1, other_id), "Other sheet"); + + h.state_mut().run("file.new", json!({})).unwrap(); + let other_doc_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other workbook"})).unwrap()["shape"].as_u64().unwrap() as u32; + h.state_mut().run("window.activate", json!({"index": 0})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap(); + h.run_steps(3); + edit_box(&mut h, id); + replace(&mut h, "Original workbook only"); + h.state_mut().run("window.activate", json!({"index": 1})).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(shape_text(&docs[0].wb, 0, id), "Original workbook only"); + assert_eq!(shape_text(&docs[1].wb, 0, other_doc_id), "Other workbook"); + assert_eq!(docs[0].wb.sheet(0).unwrap().value(at("B2")), Value::text("cell stays")); +} + +#[test] +fn protected_sheet_refuses_to_open_the_text_box_editor() { + let (mut h, id) = fixture(); + h.state_mut().session.execute("review.protectSheet", json!({})).unwrap(); + edit_box(&mut h, id); + assert!(h.state().text_box_editor.is_none(), "a protected sheet's text box must not open an editor"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet"))); + assert_eq!(shape(&h, id).text, ORIGINAL); + assert_cell_unchanged(&h); +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c72476..5245e56 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,4 @@ -//! GridCraft file formats: XLSX (Office Open XML SpreadsheetML, transitional and strict) and CSV. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -14,6 +14,7 @@ mod chart; mod csv; mod drawing; mod fmla; +mod ods; mod package; mod pivot; mod read; @@ -23,6 +24,7 @@ mod styles; mod tables; mod theme; mod write; +mod xlsb; mod xml; #[cfg(test)] @@ -32,6 +34,8 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; +pub use ods::read_ods; +pub use xlsb::read_xlsb; /// Errors from reading or writing files. #[derive(Debug, thiserror::Error)] @@ -92,14 +96,22 @@ impl Default for CsvOptions { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Xlsx, + Xlsb, + Ods, Csv, Unknown, } -/// Guesses the format from the first bytes: a zip package is XLSX, text is CSV. +/// Guesses the format from content: ODS mimetype, XLSB package content types, other ZIP packages as XLSX, text as CSV. pub fn sniff(bytes: &[u8]) -> Format { if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") { - return Format::Xlsx; + return if ods::has_mimetype(bytes) { + Format::Ods + } else if xlsb::is_xlsb(bytes) { + Format::Xlsb + } else { + Format::Xlsx + }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs new file mode 100644 index 0000000..4f63fd1 --- /dev/null +++ b/crates/xlsx/src/ods.rs @@ -0,0 +1,659 @@ +//! Data-only OpenDocument Spreadsheet import. Formula caches become constants; styles and +//! other ODF features are deliberately not translated. All fixtures are authored in Rust. +//! Format reference: ODF 1.3, part 3, sections 6.1 and 9.1: +//! . + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use gridcraft_core::date::{days_in_month, serial_from_ymd}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; +use quick_xml::NsReader; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::ResolveResult; + +use crate::package::{MAX_ENTRIES, Package}; +use crate::{IoError, ReadReport}; + +const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet"; +const OFFICE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:office:1.0"; +const TABLE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:table:1.0"; +const TEXT: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:text:1.0"; +const CALC_EXT: &[u8] = b"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"; +const MANIFEST: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; +const MAX_CELLS: u64 = 1_000_000; +const MAX_MERGES: u64 = 100_000; +const MAX_CELL_TEXT: usize = 1024 * 1024; +const MAX_TEXT: u64 = 64 * 1024 * 1024; +const MAX_SHEETS: usize = 1024; + +/// Reads only the small, exact mimetype entry, including when the ZIP uses compression. +/// Looking at a filename or at arbitrary bytes inside the archive is not sufficient. +pub(crate) fn has_mimetype(bytes: &[u8]) -> bool { + let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; + if zip.len() > MAX_ENTRIES { + return false; + } + let Ok(mut file) = zip.by_name("mimetype") else { return false }; + if file.size() != MIME.len() as u64 { + return false; + } + let mut value = Vec::new(); + (&mut file).take(MIME.len() as u64 + 1).read_to_end(&mut value).is_ok() && value == MIME +} + +/// Imports ODS sheets, typed values, displayed text and merges. Cached formula results are +/// imported as constants, so editing their inputs does not recalculate the original formula. +/// Always inspect the returned warnings. Saving to ODS is not supported. +pub fn read_ods(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let mut package = Package::open(bytes)?; + if !has_mimetype(bytes) { + return Err(IoError::Format("missing or invalid ODS mimetype".into())); + } + if let Some(manifest) = package.read("META-INF/manifest.xml")? { + check_manifest(&manifest)?; + } + let content = package.read("content.xml")?.ok_or_else(|| IoError::Format("ODS content.xml is missing".into()))?; + let mut import = Import::new(); + import.parse(&content)?; + if import.wb.sheets.is_empty() { + return Err(IoError::Format("ODS contains no worksheets".into())); + } + Ok((import.wb, import.report)) +} + +fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool { + matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected) +} + +fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result, IoError> { + let mut found = None; + for a in e.attributes() { + let a = a.map_err(|e| IoError::Xml(e.to_string()))?; + let (resolved, local) = reader.resolve_attribute(a.key); + if is_ns(resolved, ns) && local.as_ref() == name { + let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?; + if value.len() > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into())); + } + found = Some(value.into_owned()); + } + } + Ok(found) +} + +fn positive(value: Option, default: u32, limit: u32) -> Result { + let n = match value { + Some(s) => s.parse::().map_err(|_| IoError::Format("invalid ODS repeat or span count".into()))?, + None => default, + }; + if n == 0 || n > limit { + return Err(IoError::TooLarge("ODS repeat or span exceeds worksheet bounds".into())); + } + Ok(n) +} + +fn xml_error(e: impl std::fmt::Display) -> IoError { + IoError::Xml(format!("ODS: {e}")) +} + +fn check_manifest(bytes: &[u8]) -> Result<(), IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut depth = 0usize; + let mut root_seen = false; + loop { + let event = reader.read_event().map_err(xml_error)?; + match &event { + Event::Start(e) | Event::Empty(e) => { + let (ns, name) = reader.resolve_element(e.name()); + if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" { + return Err(IoError::Format("encrypted ODS files are not supported".into())); + } + if depth == 0 { + if root_seen || !is_ns(ns, MANIFEST) || name.as_ref() != b"manifest" { + return Err(xml_error("expected one manifest:manifest root")); + } + root_seen = true; + } + if matches!(event, Event::Start(_)) { + depth += 1; + if depth > crate::xml::MAX_DEPTH { + return Err(xml_error("manifest nested too deeply")); + } + } + } + Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| xml_error("unexpected manifest closing element"))?, + Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")), + Event::Eof => break, + _ => {} + } + } + if !root_seen || depth != 0 { + return Err(xml_error("incomplete manifest")); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tag { + Document, + Body, + Spreadsheet, + Table, + Rows, + Row, + Cell, + Covered, + Paragraph, + Inline, + Space, + Tab, + Break, + Other, +} + +fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag { + let (ns, name) = reader.resolve_element(e.name()); + let local = name.as_ref(); + if is_ns(ns.clone(), OFFICE) { + return match local { + b"document-content" => Tag::Document, + b"body" => Tag::Body, + b"spreadsheet" => Tag::Spreadsheet, + _ => Tag::Other, + }; + } + if is_ns(ns.clone(), TABLE) { + return match local { + b"table" => Tag::Table, + b"table-row-group" | b"table-header-rows" | b"table-rows" => Tag::Rows, + b"table-row" => Tag::Row, + b"table-cell" => Tag::Cell, + b"covered-table-cell" => Tag::Covered, + _ => Tag::Other, + }; + } + if is_ns(ns, TEXT) { + return match local { + b"p" | b"h" => Tag::Paragraph, + b"span" | b"a" | b"meta" | b"meta-field" | b"ruby" | b"ruby-base" => Tag::Inline, + b"s" => Tag::Space, + b"tab" => Tag::Tab, + b"line-break" => Tag::Break, + _ => Tag::Other, + }; + } + Tag::Other +} + +struct SheetDraft { + sheet: Sheet, + depth: usize, + row: u32, +} +struct RowDraft { + depth: usize, + repeat: u32, + col: u32, + cells: Vec, +} +struct RepeatedCell { + col: u32, + repeat: u32, + width: u32, + height: u32, + cell: Cell, +} +struct CellDraft { + depth: usize, + repeat: u32, + width: u32, + height: u32, + covered: bool, + kind: Option, + raw: Option, + formula: bool, + text: String, + paragraph: Option, + paragraphs: usize, + paragraph_started: bool, + pending_space: bool, +} +struct Import { + wb: Workbook, + report: ReadReport, + stack: Vec, + sheet: Option, + row: Option, + cell: Option, + cells: u64, + merges: u64, + text: u64, + formats: Vec<(&'static str, StyleId)>, + spreadsheet_seen: bool, +} + +impl Import { + fn new() -> Self { + let mut wb = Workbook::new(); + wb.sheets.clear(); + let mut report = ReadReport::default(); + report.warn("ODS data-only import: formulas become their saved results and will not recalculate. Source formatting, charts, images and other features are not imported. Save a new .xlsx file; ODS saving is not supported."); + Self { wb, report, stack: vec![], sheet: None, row: None, cell: None, cells: 0, merges: 0, text: 0, formats: vec![], spreadsheet_seen: false } + } + + fn parse(&mut self, bytes: &[u8]) -> Result<(), IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut root_seen = false; + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(e) => { + if self.stack.len() >= crate::xml::MAX_DEPTH { + return Err(xml_error("XML nested too deeply")); + } + if self.stack.is_empty() { + if root_seen || tag(&reader, &e) != Tag::Document { + return Err(xml_error("expected one office:document-content root")); + } + root_seen = true; + } + self.start(&reader, &e)?; + } + Event::Empty(e) => { + if self.stack.is_empty() { + return Err(xml_error("expected office:document-content with a spreadsheet")); + } + self.start(&reader, &e)?; + self.end()?; + } + Event::End(_) => self.end()?, + Event::Text(t) => { + let text = t.xml_content().map_err(xml_error)?; + if self.stack.is_empty() && !text.trim().is_empty() { + return Err(xml_error("text outside the document")); + } + self.append(&text)?; + } + Event::CData(t) => self.append(&t.decode().map_err(xml_error)?)?, + Event::GeneralRef(r) => { + let ch = match r.resolve_char_ref().map_err(xml_error)? { + Some(c) => c, + None => match r.as_ref() { + b"amp" => '&', + b"lt" => '<', + b"gt" => '>', + b"apos" => '\'', + b"quot" => '"', + _ => return Err(xml_error("unsupported XML entity")), + }, + }; + self.append(ch.encode_utf8(&mut [0; 4]))?; + } + Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")), + Event::Eof => break, + _ => {} + } + } + if !root_seen || !self.stack.is_empty() || !self.spreadsheet_seen { + return Err(xml_error("incomplete ODS document")); + } + Ok(()) + } + + fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> { + let t = tag(reader, e); + let depth = self.stack.len(); + match t { + Tag::Spreadsheet if self.stack == [Tag::Document, Tag::Body] => { + if self.spreadsheet_seen { + return Err(xml_error("multiple spreadsheets in one document")); + } + self.spreadsheet_seen = true; + } + Tag::Table if self.stack == [Tag::Document, Tag::Body, Tag::Spreadsheet] => { + if self.wb.sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into())); + } + let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default(); + let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len()); + if self.wb.check_sheet_name(&name, None).is_err() { + name = self.wb.next_sheet_name(); + } + if name != original { + self.report.warn("Some ODS sheet names were shortened or renamed to valid, unique XLSX sheet names."); + } + self.sheet = Some(SheetDraft { sheet: Sheet::new(name), depth, row: 0 }); + } + Tag::Row if self.sheet.as_ref().is_some_and(|s| self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => { + self.row = + Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, u32::MAX)?, col: 0, cells: vec![] }); + } + Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => { + let kind = if attr(reader, e, CALC_EXT, b"value-type")?.as_deref() == Some("error") { + Some("error".into()) + } else { + attr(reader, e, OFFICE, b"value-type")? + }; + let key: &[u8] = match kind.as_deref() { + Some("boolean") => b"boolean-value", + Some("date") => b"date-value", + Some("time") => b"time-value", + Some("string") => b"string-value", + _ => b"value", + }; + self.cell = Some(CellDraft { + depth, + repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, u32::MAX)?, + width: positive(attr(reader, e, TABLE, b"number-columns-spanned")?, 1, MAX_COLS)?, + height: positive(attr(reader, e, TABLE, b"number-rows-spanned")?, 1, MAX_ROWS)?, + covered: t == Tag::Covered, + kind, + raw: attr(reader, e, OFFICE, key)?, + formula: attr(reader, e, TABLE, b"formula")?.is_some(), + text: String::new(), + paragraph: None, + paragraphs: 0, + paragraph_started: false, + pending_space: false, + }); + } + Tag::Paragraph if self.cell.as_ref().is_some_and(|c| depth == c.depth + 1) => { + if let Some(cell) = self.cell.as_mut() { + if cell.paragraphs > 0 { + push_text(&mut cell.text, "\n")?; + } + cell.paragraph = Some(depth); + cell.paragraphs += 1; + cell.paragraph_started = false; + cell.pending_space = false; + } + } + Tag::Space | Tag::Tab | Tag::Break if self.captures_text() => { + let count = if t == Tag::Space { + let n = attr(reader, e, TEXT, b"c")?.map(|s| s.parse::()).transpose().map_err(xml_error)?.unwrap_or(1); + if n as usize > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS repeated spaces exceed 1 MiB".into())); + } + n + } else { + 1 + }; + let value = match t { + Tag::Space => " ", + Tag::Tab => "\t", + _ => "\n", + }; + if let Some(cell) = self.cell.as_mut() { + if cell.pending_space { + push_text(&mut cell.text, " ")?; + cell.pending_space = false; + } + cell.paragraph_started = true; + if cell.text.len().saturating_add(count as usize) > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into())); + } + for _ in 0..count { + cell.text.push_str(value); + } + } + } + _ => {} + } + self.stack.push(t); + Ok(()) + } + + fn captures_text(&self) -> bool { + self.cell + .as_ref() + .and_then(|c| c.paragraph) + .is_some_and(|depth| self.stack.get(depth..).is_some_and(|p| p.iter().all(|t| matches!(t, Tag::Paragraph | Tag::Inline)))) + } + + fn append(&mut self, text: &str) -> Result<(), IoError> { + if self.captures_text() + && let Some(cell) = self.cell.as_mut() + { + // ODF 1.3 section 6.1.2: collapse XML whitespace across inline runs, + // but expand explicit text:s/tab/line-break elements separately. + for ch in text.chars() { + if matches!(ch, ' ' | '\t' | '\n' | '\r') { + cell.pending_space = cell.paragraph_started; + } else { + if cell.pending_space { + push_text(&mut cell.text, " ")?; + cell.pending_space = false; + } + push_text(&mut cell.text, ch.encode_utf8(&mut [0; 4]))?; + cell.paragraph_started = true; + } + } + } + Ok(()) + } + + fn end(&mut self) -> Result<(), IoError> { + self.stack.pop().ok_or_else(|| xml_error("unexpected closing element"))?; + let depth = self.stack.len(); + if let Some(cell) = self.cell.as_mut() + && cell.paragraph == Some(depth) + { + cell.paragraph = None; + } + if self.cell.as_ref().is_some_and(|c| c.depth == depth) { + self.finish_cell()?; + } + if self.row.as_ref().is_some_and(|r| r.depth == depth) { + self.finish_row()?; + } + if self.sheet.as_ref().is_some_and(|s| s.depth == depth) + && let Some(sheet) = self.sheet.take() + { + self.wb.sheets.push(Arc::new(sheet.sheet)); + } + Ok(()) + } + + fn finish_cell(&mut self) -> Result<(), IoError> { + let Some(draft) = self.cell.take() else { return Ok(()) }; + let cell = self.convert(&draft); + let (width, height) = if draft.covered { (1, 1) } else { (draft.width, draft.height) }; + let Some(row) = self.row.as_mut() else { return Ok(()) }; + let kept = !cell.is_blank() || width > 1 || height > 1; + // Writers pad rows with blank repeats up to their own (possibly wider) sheet edge; + // blank cells past ours carry no data, so clip them instead of rejecting the file. + let end = match row.col.checked_add(draft.repeat).filter(|n| *n <= MAX_COLS) { + Some(end) => end, + None if !kept => MAX_COLS, + None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())), + }; + if kept { + row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell }); + } + row.col = end; + Ok(()) + } + + fn convert(&mut self, draft: &CellDraft) -> Cell { + let raw = draft.raw.as_deref(); + let mut format = None; + let value = match draft.kind.as_deref() { + Some("float" | "percentage" | "currency") => raw.and_then(|s| s.parse::().ok()).filter(|n| n.is_finite()).map(|n| { + if draft.kind.as_deref() == Some("percentage") { + format = Some("0.00%"); + } + Value::Number(n) + }), + Some("boolean") => match raw { + Some("true" | "1") => Some(Value::Bool(true)), + Some("false" | "0") => Some(Value::Bool(false)), + _ => None, + }, + Some("string") if raw.is_some() || draft.paragraphs > 0 || !draft.formula => Some(Value::Text(Arc::from(raw.unwrap_or(&draft.text)))), + Some("void") if !draft.formula => Some(Value::Empty), + Some("date") => raw.and_then(parse_date).map(|(n, f)| { + format = Some(f); + Value::Number(n) + }), + Some("time") => raw.and_then(parse_duration).map(|n| { + format = Some("[h]:mm:ss.000"); + Value::Number(n) + }), + None if !draft.formula => Some(if draft.text.is_empty() { Value::Empty } else { Value::Text(Arc::from(draft.text.as_str())) }), + _ => None, + }; + let value = value.unwrap_or_else(|| { + self.report.warn("Some ODS values or formula caches were unsupported or missing; their displayed text (or raw value) was kept as text. A formula without either is marked [Unavailable ODS formula result]."); + let text = if !draft.text.is_empty() { draft.text.as_str() } else if let Some(raw) = raw { raw } else if draft.formula { "[Unavailable ODS formula result]" } else { "[Unsupported ODS value]" }; + Value::Text(Arc::from(text)) + }); + let style = match format { + Some(code) => match self.formats.iter().find(|(f, _)| *f == code) { + Some((_, id)) => *id, + None => { + let id = self.wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + self.formats.push((code, id)); + id + } + }, + None => StyleId::DEFAULT, + }; + Cell { style, ..Cell::value(value) } + } + + fn finish_row(&mut self) -> Result<(), IoError> { + let Some(row) = self.row.take() else { return Ok(()) }; + let Some(sheet) = self.sheet.as_mut() else { return Ok(()) }; + // Blank trailing row repeats past the sheet edge (e.g. from larger-sheet writers) are clipped. + let end = match sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS) { + Some(end) => end, + None if row.cells.is_empty() => MAX_ROWS, + None => return Err(IoError::TooLarge("ODS exceeds worksheet height".into())), + }; + for entry in row.cells { + let count = u64::from(entry.repeat) * u64::from(row.repeat); + if !entry.cell.is_blank() { + self.cells = self.cells.saturating_add(count); + if let Value::Text(t) = &entry.cell.value { + self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64)); + } + } + let merged = entry.width > 1 || entry.height > 1; + if merged { + self.merges = self.merges.saturating_add(count); + } + if self.cells > MAX_CELLS || self.merges > MAX_MERGES || self.text > MAX_TEXT { + return Err(IoError::TooLarge("ODS expands beyond the import limit (1 million cells, 100000 merges or 64 MiB of text)".into())); + } + let last_col = entry.col.checked_add(entry.repeat - 1).and_then(|c| c.checked_add(entry.width - 1)); + let last_row = end.checked_add(entry.height - 1); + if last_col.is_none_or(|c| c >= MAX_COLS) || last_row.is_none_or(|r| r > MAX_ROWS) { + return Err(IoError::TooLarge("ODS merged cell exceeds worksheet bounds".into())); + } + if merged && ((entry.repeat > 1 && entry.width > 1) || (row.repeat > 1 && entry.height > 1)) { + return Err(IoError::Format("repeated ODS cells have overlapping merges".into())); + } + for r in sheet.row..end { + for c in entry.col..entry.col + entry.repeat { + let pos = CellRef::new(r, c); + if !entry.cell.is_blank() { + sheet.sheet.cells.set(pos, entry.cell.clone()); + } + if merged { + sheet.sheet.merges.push(RangeRef::new(pos, CellRef::new(r + entry.height - 1, c + entry.width - 1))); + } + } + } + } + sheet.row = end; + Ok(()) + } +} + +fn push_text(out: &mut String, text: &str) -> Result<(), IoError> { + if out.len().saturating_add(text.len()) > MAX_CELL_TEXT { + return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into())); + } + out.push_str(text); + Ok(()) +} + +/// Support unzoned ISO calendar dates and times. A timezone, invalid date or out-of-range +/// calendar year remains text; never silently discard an offset or roll an invalid date. +fn parse_date(s: &str) -> Option<(f64, &'static str)> { + let (date, time) = s.split_once('T').map_or((s, None), |(d, t)| (d, Some(t))); + if date.len() != 10 || date.get(4..5)? != "-" || date.get(7..8)? != "-" { + return None; + } + let year: i32 = digits(date.get(..4)?)?.parse().ok()?; + let month: u32 = digits(date.get(5..7)?)?.parse().ok()?; + let day: u32 = digits(date.get(8..10)?)?.parse().ok()?; + if !(1900..=9999).contains(&year) || !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return None; + } + let day = serial_from_ymd(DateSystem::D1900, year.into(), month.into(), day.into())?; + let Some(time) = time else { return Some((day, "yyyy-mm-dd")) }; + let mut parts = time.split(':'); + let h = parts.next()?; + let m = parts.next()?; + let sec = parts.next()?; + if parts.next().is_some() || h.len() != 2 || m.len() != 2 || sec.split('.').next()?.len() != 2 { + return None; + } + let h: u32 = digits(h)?.parse().ok()?; + let m: u32 = digits(m)?.parse().ok()?; + let sec = decimal(sec)?; + if h >= 24 || m >= 60 || sec >= 60.0 { + return None; + } + Some((day + (h as f64 * 3600.0 + m as f64 * 60.0 + sec) / 86400.0, "yyyy-mm-dd hh:mm:ss.000")) +} + +fn digits(s: &str) -> Option<&str> { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then_some(s) +} +fn decimal(s: &str) -> Option { + match s.split_once('.') { + Some((a, b)) => { + digits(a)?; + digits(b)?; + } + None => { + digits(s)?; + } + } + s.parse::().ok().filter(|n| n.is_finite()) +} + +/// A bounded ISO day/time duration. Calendar years/months cannot be represented as days. +fn parse_duration(s: &str) -> Option { + let (negative, s) = s.strip_prefix('-').map_or((false, s), |s| (true, s)); + let mut rest = s.strip_prefix('P')?; + let mut total = 0.0; + let mut any = false; + if let Some((days, tail)) = rest.split_once('D') { + total += digits(days)?.parse::().ok()? * 86400.0; + rest = tail; + any = true; + } + if let Some(mut time) = rest.strip_prefix('T') { + let mut time_any = false; + for (unit, factor) in [('H', 3600.0), ('M', 60.0), ('S', 1.0)] { + if let Some((n, tail)) = time.split_once(unit) { + let value = if unit == 'S' { decimal(n)? } else { digits(n)?.parse::().ok()? }; + total += value * factor; + time = tail; + time_any = true; + } + } + if !time.is_empty() || !time_any { + return None; + } + any = true; + } else if !rest.is_empty() { + return None; + } + let days = total / 86400.0; + (any && days.is_finite() && days <= 2_958_465.0).then_some(if negative { -days } else { days }) +} diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index e6cade6..9a28999 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -58,22 +58,28 @@ impl<'a> Package<'a> { /// Reads a part's bytes; `Ok(None)` when it doesn't exist. pub fn read(&mut self, part: &str) -> Result>, IoError> { + self.read_limited(part, MAX_PART) + } + + /// Reads a small metadata part without allowing it to expand to the general part limit. + pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result>, IoError> { + let maximum = maximum.min(MAX_PART); let Some(&idx) = self.names.get(&normalize(part)) else { return Ok(None); }; let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; - if f.size() > MAX_PART { + if f.size() > maximum { return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size()))); } let remaining = MAX_TOTAL.saturating_sub(self.total); - let limit = MAX_PART.min(remaining); + let limit = maximum.min(remaining); let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize); (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?; if out.len() as u64 > limit { - return Err(IoError::TooLarge(if limit < MAX_PART { + return Err(IoError::TooLarge(if remaining < maximum { "package expands to more than 1 GB".into() } else { - format!("{part} expands to more than 512 MB") + format!("{part} expands to more than {maximum} bytes") })); } self.total += out.len() as u64; diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index bf45e91..6f197fb 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -285,7 +285,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Ok((wb, cx.report)) } -fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { +pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String { let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); if base.trim().is_empty() { base = format!("Sheet{}", i + 1); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997..96b4302 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -3,8 +3,10 @@ mod csv_tests; mod fixtures; mod malformed; +mod ods_tests; mod pivot_tests; mod roundtrip; +mod xlsb_tests; use std::io::Write; diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs new file mode 100644 index 0000000..c0f2e0e --- /dev/null +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -0,0 +1,238 @@ +//! Original ODS packages assembled in Rust, not files from another spreadsheet application. + +use gridcraft_core::{CellRef, RangeRef, Value}; + +use super::make_zip; +use crate::{Format, IoError, read_ods, read_xlsx, sniff, write_xlsx}; + +const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet"; +const MANIFEST: &[u8] = br#""#; + +fn content(tables: &str) -> String { + format!( + r#"{tables}"# + ) +} +fn ods(tables: &str) -> Vec { + make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", content(tables).as_bytes())]) +} +fn row(cells: &str) -> Vec { + ods(&format!(r#"{cells}"#)) +} +fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +#[test] +fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() { + let bytes = row(r#" + + + + + + 85 + #DIV/0! + + unavailable + + "#); + let (wb, report) = read_ods(&bytes).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("data-only") && w.contains("will not recalculate"))); + assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing"))); + let expected = [ + Value::Number(42.5), + Value::Bool(true), + Value::Number(0.25), + Value::Number(19.99), + text("literal =1+1"), + Value::Number(85.0), + text("#DIV/0!"), + text("[Unavailable ODS formula result]"), + text("unavailable"), + Value::Bool(false), + ]; + for (c, value) in expected.iter().enumerate() { + assert_eq!(&wb.sheets[0].value(CellRef::new(0, c as u32)), value); + assert!(wb.sheets[0].cell(CellRef::new(0, c as u32)).unwrap().formula.is_none()); + } + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for (c, value) in expected.iter().enumerate() { + assert_eq!(&reopened.sheets[0].value(CellRef::new(0, c as u32)), value); + } +} + +#[test] +fn ods_ordered_text_preserves_paragraphs_runs_entities_spaces_tabs_and_breaks() { + let bytes = row( + r#"one two & three fourfivesixseven!]]>ninenot cell text"#, + ); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!\nnine")); + let bytes = row(r#" one + two three four five "#); + assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("one two three four \n five")); +} + +#[test] +fn ods_repeats_merges_sheet_names_and_empty_tail_remain_sparse() { + let bytes = ods(r#" + + merged + + + + + + "#); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets.len(), 2); + assert_eq!(wb.sheets[0].name, "First & Second"); + assert_eq!(wb.sheets[0].cells.len(), 8); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(99.0)); + assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1))]); + assert_eq!(wb.sheets[0].value(CellRef::new(3, 2)), Value::Number(7.0)); + assert_eq!(wb.sheets[1].value(CellRef::new(0, 0)), text("second sheet")); +} + +#[test] +fn ods_dates_and_durations_use_formats_and_unsupported_values_stay_text() { + let bytes = row(r#" + + + + + + + + + "#); + let (wb, report) = read_ods(&bytes).unwrap(); + let s = &wb.sheets[0]; + assert_eq!(s.value(CellRef::new(0, 0)), Value::Number(59.0)); + assert_eq!(s.value(CellRef::new(0, 1)), Value::Number(46302.0 + 66600.125 / 86400.0)); + assert_eq!(s.value(CellRef::new(0, 2)), Value::Number(1.0 + 7384.5 / 86400.0)); + assert_eq!(s.value(CellRef::new(0, 3)), Value::Number(-2.0 / 24.0)); + for (c, value) in [(4, "1900-02-29"), (5, "2026-10-07T18:00:00Z"), (6, "P1M"), (7, "PT2M1H")] { + assert_eq!(s.value(CellRef::new(0, c)), text(value)); + } + assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 0)).unwrap().style).num_fmt.0.as_ref(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 2)).unwrap().style).num_fmt.0.as_ref(), "[h]:mm:ss.000"); + assert_eq!(report.warnings.len(), 2); +} + +#[test] +fn ods_namespaces_are_resolved_not_inferred_from_prefix_or_local_name() { + let xml = content(r#""#) + .replace("kept"#); + assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("kept")); +} + +#[test] +fn ods_rejects_bad_packages_encryption_and_truncated_xml() { + assert!(read_ods(b"not zip").is_err()); + let xml = content(r#""#); + for mime in [b"application/zip".as_slice(), b"application/vnd.oasis.opendocument.spreadsheet "] { + assert!(read_ods(&make_zip(&[("mimetype", mime), ("content.xml", xml.as_bytes())])).is_err()); + } + let encrypted = + br#""#; + assert!( + read_ods(&make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", encrypted), ("content.xml", xml.as_bytes())])) + .unwrap_err() + .to_string() + .contains("encrypted") + ); + for bad in [ + xml.trim_end_matches("").to_owned(), + xml.replace("", ""), + "".to_owned(), + format!("{xml}{xml}"), + ] { + assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", bad.as_bytes())])).is_err(), "{bad}"); + } + let deep = content(&format!("{}{}", "".repeat(300), "".repeat(300))); + assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", deep.as_bytes())])).is_err()); +} + +#[test] +fn ods_expansion_bounds_stop_small_repeat_bombs() { + for cells in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(read_ods(&row(cells)).is_err(), "{cells}"); + } + let cells = ods( + r#""#, + ); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + let text = format!( + r#""#, + "x".repeat(1024) + ); + assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_)))); +} + +#[test] +fn ods_sniff_requires_exact_bounded_mimetype() { + assert_eq!(sniff(&row("")), Format::Ods); + assert_eq!(sniff(&make_zip(&[("mimetype", b"application/zip")])), Format::Xlsx); + assert_eq!(sniff(&make_zip(&[("elsewhere", MIME)])), Format::Xlsx); + assert_eq!(sniff(&make_zip(&[("mimetype", &vec![b'x'; 1_000_000])])), Format::Xlsx); + assert_eq!(sniff(b"a,b\n1,2"), Format::Csv); +} + +#[test] +fn ods_invalid_sheet_names_are_made_safe_for_xlsx() { + let long = "A".repeat(50); + let bytes = ods(&format!( + r#""# + )); + let (wb, report) = read_ods(&bytes).unwrap(); + for (i, sheet) in wb.sheets.iter().enumerate() { + assert!(wb.check_sheet_name(&sheet.name, Some(i)).is_ok()); + } + assert!(report.warnings.iter().any(|w| w.contains("sheet names"))); + assert_eq!(read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0.sheets.len(), 4); +} + +#[test] +fn ods_extension_error_cache_never_imports_its_placeholder_zero() { + let bytes = row( + r#"#DIV/0!"#, + ); + let (wb, report) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("#DIV/0!")); + assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing"))); +} + +#[test] +fn ods_blank_repeats_past_the_sheet_edge_are_clipped() { + // Writers with larger sheets pad to their own edge; blank padding past ours carries no data. + let bytes = ods(r#" + + + + "#); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].cells.len(), 1); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), Value::Number(1.0)); + // Data after the clipped edge is still an error, never silently dropped. + let late = ods( + r#""#, + ); + assert!(matches!(read_ods(&late), Err(IoError::TooLarge(_)))); +} diff --git a/crates/xlsx/src/tests/xlsb_tests.rs b/crates/xlsx/src/tests/xlsb_tests.rs new file mode 100644 index 0000000..969a380 --- /dev/null +++ b/crates/xlsx/src/tests/xlsb_tests.rs @@ -0,0 +1,326 @@ +//! Original BIFF12 records and ZIP packages assembled in Rust from the public format schema. +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; +use gridcraft_model::Visibility; + +use super::make_zip; +use crate::{Format, read_xlsb, read_xlsx, sniff, write_xlsx}; + +const ROOT: &str = r#""#; +const RELS: &str = r#""#; +const TYPES: &str = r#""#; + +fn record(id: u16, bytes: &[u8]) -> Vec { + let mut out = vec![]; + if id < 128 { + out.push(id as u8); + } else { + out.extend([(id as u8 & 0x7f) | 0x80, (id >> 7) as u8]); + } + let mut len = bytes.len(); + loop { + let byte = (len & 0x7f) as u8; + len >>= 7; + out.push(if len == 0 { byte } else { byte | 0x80 }); + if len == 0 { + break; + } + } + out.extend(bytes); + out +} +fn wide(text: &str) -> Vec { + let units: Vec<_> = text.encode_utf16().collect(); + let mut out = (units.len() as u32).to_le_bytes().to_vec(); + for unit in units { + out.extend(unit.to_le_bytes()); + } + out +} +fn rich(text: &str, extras: bool) -> Vec { + let mut out = vec![if extras { 3 } else { 0 }]; + out.extend(wide(text)); + if extras { + out.extend(1u32.to_le_bytes()); + out.extend([0, 0, 0, 0]); // One original formatting run. + out.extend(wide("sound")); + out.extend(1u32.to_le_bytes()); + out.extend([0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); // One ten-byte phonetic run. + } + out +} +fn row(r: u32) -> Vec { + let mut out = r.to_le_bytes().to_vec(); + out.extend([0; 13]); + record(0, &out) +} +fn cell(id: u16, col: u32, value: &[u8]) -> Vec { + let mut data = col.to_le_bytes().to_vec(); + data.extend([0; 4]); + data.extend(value); + if (8..=11).contains(&id) { + data.extend([0; 2]); // Formula flags. + data.extend(3u32.to_le_bytes()); + data.extend([0x1e, 1, 0]); // Original PtgInt(1); ignored because we import the cache. + data.extend(0u32.to_le_bytes()); + } + record(id, &data) +} +fn worksheet(data: &[u8], tail: &[u8]) -> Vec { + let mut out = record(129, &[]); + out.extend(record(148, &[0xff; 16])); // A bogus dimension must never allocate a dense grid. + out.extend(record(145, &[])); + out.extend(data); + out.extend(record(146, &[])); + out.extend(tail); + out.extend(record(130, &[])); + out +} +fn book(date1904: bool) -> Vec { + let mut out = record(131, &[]); + let mut props = u32::from(date1904).to_le_bytes().to_vec(); + props.extend(0u32.to_le_bytes()); + props.extend(wide("")); + out.extend(record(153, &props)); + out.extend(record(143, &[])); + for (id, rel, name, state) in [(1u32, "one", "Data", 0u32), (2, "two", "Hidden", 1)] { + let mut data = state.to_le_bytes().to_vec(); + data.extend(id.to_le_bytes()); + data.extend(wide(rel)); + data.extend(wide(name)); + out.extend(record(156, &data)); + } + out.extend(record(144, &[])); + out.extend(record(132, &[])); + out +} +fn sst() -> Vec { + let mut out = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]); + out.extend(record(19, &rich("shared 🦀 café", true))); + out.extend(record(160, &[])); + out +} +fn package(book: &[u8], sheet: &[u8], strings: &[u8], roots: &str, rels: &str, types: &str) -> Vec { + make_zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", roots.as_bytes()), + ("records/book.bin", book), + ("records/_rels/book.bin.rels", rels.as_bytes()), + ("data/one.bin", sheet), + ("data/two.bin", &worksheet(&[], &[])), + ("text/strings.bin", strings), + ]) +} +fn file(sheet: &[u8]) -> Vec { + package(&book(false), sheet, &sst(), ROOT, RELS, TYPES) +} +fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +#[test] +fn xlsb_scalar_caches_rk_unicode_visibility_and_epochs_survive_xlsx_save() { + let mut data = row(2); + let real_rk = (3.5f64.to_bits() >> 32) as u32; + let encoded = [ + cell(1, 0, &[]), + cell(2, 1, &((-123i32 << 2) | 2).to_le_bytes()), + cell(2, 2, &((123i32 << 2) | 3).to_le_bytes()), + cell(2, 3, &real_rk.to_le_bytes()), + cell(2, 4, &(real_rk | 1).to_le_bytes()), + cell(3, 5, &[0x17]), + cell(4, 6, &[1]), + cell(5, 7, &123.5f64.to_le_bytes()), + cell(6, 8, &wide("inline λ")), + cell(7, 9, &0u32.to_le_bytes()), + cell(8, 10, &wide("cached text")), + cell(9, 11, &2.0f64.to_le_bytes()), + cell(10, 12, &[0]), + cell(11, 13, &[7]), + cell(62, 14, &rich("rich text", true)), + ]; + for cell in encoded { + data.extend(cell); + } + let mut merge = vec![]; + for n in [5u32, 6, 0, 1] { + merge.extend(n.to_le_bytes()); + } + let sheet = worksheet(&data, &record(176, &merge)); + let expected = [ + Value::Empty, + Value::Number(-123.0), + Value::Number(1.23), + Value::Number(3.5), + Value::Number(0.035), + Value::Error(CellError::Ref), + Value::Bool(true), + Value::Number(123.5), + text("inline λ"), + text("shared 🦀 café"), + text("cached text"), + Value::Number(2.0), + Value::Bool(false), + Value::Error(CellError::Div0), + text("rich text"), + ]; + for epoch in [false, true] { + let bytes = package(&book(epoch), &sheet, &sst(), ROOT, RELS, TYPES); + assert_eq!(sniff(&bytes), Format::Xlsb); + let (wb, report) = read_xlsb(&bytes).unwrap(); + assert_eq!(wb.date_system, if epoch { DateSystem::D1904 } else { DateSystem::D1900 }); + assert_eq!(wb.sheets.len(), 2); + assert_eq!(wb.sheets[1].visibility, Visibility::Hidden); + assert_eq!(wb.sheets[0].cells.len(), 14); + assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(5, 0), CellRef::new(6, 1))]); + assert!(report.warnings.iter().any(|w| w.contains("will not recalculate") && w.contains("may display as numbers"))); + for (col, expected) in expected.iter().enumerate() { + assert_eq!(&wb.sheets[0].value(CellRef::new(2, col as u32)), expected); + } + assert!(wb.sheets[0].cells.iter().all(|(_, c)| c.formula.is_none())); + let (roundtrip, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(roundtrip.date_system, wb.date_system); + for (col, expected) in expected.iter().enumerate() { + assert_eq!(&roundtrip.sheets[0].value(CellRef::new(2, col as u32)), expected); + } + } +} + +#[test] +fn xlsb_extension_blocks_are_skipped_with_strict_boundaries_and_record_framing() { + let mut data = row(0); + data.extend(cell(5, 0, &1.0f64.to_le_bytes())); + data.extend(record(35, &[0; 4])); + data.extend(record(37, &[1, 0, 0, 0, 0, 0])); + data.extend(cell(5, 1, &99.0f64.to_le_bytes())); + data.extend(record(130, &[])); // Looks like EndSheet, but is inside an ignored extension. + data.extend(record(38, &[])); + data.extend(record(36, &[])); + data.extend([0xe8, 0x87, 0x80, 0x80, 0x80, 0x80]); // ID1000: ignored high second-ID/fourth-length bits. + data.extend(cell(5, 1, &2.0f64.to_le_bytes())); + let (wb, report) = read_xlsb(&file(&worksheet(&data, &[]))).unwrap(); + assert_eq!(wb.sheets[0].cells.len(), 2); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(2.0)); + assert!(report.warnings.iter().any(|w| w.contains("blocks were skipped"))); + for bad in [ + vec![0x81], + vec![0x81, 0], + vec![1, 0x80], + vec![1, 10, 0], + record(36, &[]), + [record(35, &[0; 4]), record(38, &[])].concat(), + record(35, &[0; 4]), + record(37, &[0, 0]), + ] { + assert!(read_xlsb(&file(&worksheet(&bad, &[]))).is_err(), "{bad:?}"); + } + let nested = record(35, &[0; 4]).repeat(65); + assert!(read_xlsb(&file(&worksheet(&nested, &[]))).unwrap_err().to_string().contains("nested too deeply")); + let mut missing_end = worksheet(&[], &[]); + missing_end.truncate(missing_end.len() - record(130, &[]).len()); + assert!(read_xlsb(&file(&missing_end)).is_err()); +} + +#[test] +fn xlsb_bad_values_strings_and_coordinates_fail_without_silent_data_loss() { + let malformed = [ + cell(4, 0, &[2]), + cell(3, 0, &[0xff]), + cell(5, 0, &f64::INFINITY.to_le_bytes()), + cell(2, 0, &0x7ff00000u32.to_le_bytes()), + cell(7, 0, &1u32.to_le_bytes()), + cell(6, 0, &[1, 0, 0, 0, 0, 0xd8]), + cell(6, 0, &u32::MAX.to_le_bytes()), + cell(5, 16384, &1.0f64.to_le_bytes()), + cell(5, 0, &[0; 3]), + cell(62, 0, &[1, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), + ]; + for bad in malformed { + assert!(read_xlsb(&file(&worksheet(&[row(0), bad].concat(), &[]))).is_err()); + } + assert!(read_xlsb(&file(&worksheet(&cell(5, 0, &1.0f64.to_le_bytes()), &[]))).is_err()); + assert!(read_xlsb(&file(&worksheet(&row(1048576), &[]))).is_err()); + let repeated = [row(0), cell(5, 0, &1.0f64.to_le_bytes()), cell(5, 0, &2.0f64.to_le_bytes())].concat(); + assert!(read_xlsb(&file(&worksheet(&repeated, &[]))).is_err()); + let mut formula_payload = [0u8; 18].to_vec(); // Cell header + f64 cache + flags, but no parsed-formula structure. + assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err()); + formula_payload.extend(0u32.to_le_bytes()); // Invalid zero token count. + assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err()); + let bad_sst = [record(159, &[1, 0, 0, 0, 2, 0, 0, 0]), record(160, &[])].concat(); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err()); +} + +#[test] +fn xlsb_requires_internal_typed_relationships_and_sniffs_only_root_workbook() { + let sheet = worksheet(&[], &[]); + for (roots, rels, types) in [ + (ROOT.replace("officeDocument\"", "wrong\""), RELS.to_owned(), TYPES.to_owned()), + (ROOT.replace("Target=", "TargetMode=\"External\" Target="), RELS.to_owned(), TYPES.to_owned()), + (ROOT.to_owned(), RELS.replace("Target=\"../data/one.bin\"", "Target=\"missing.bin\""), TYPES.to_owned()), + ( + ROOT.to_owned(), + RELS.replace("Target=\"../data/one.bin\"", "TargetMode=\"External\" Target=\"https://example.com/data.bin\""), + TYPES.to_owned(), + ), + ( + ROOT.to_owned(), + RELS.replace("Target=\"../text/strings.bin\"", "TargetMode=\"External\" Target=\"https://example.com/strings.bin\""), + TYPES.to_owned(), + ), + (ROOT.to_owned(), RELS.to_owned(), TYPES.replace("application/vnd.ms-excel.worksheet", "application/xml")), + (ROOT.to_owned(), RELS.trim_end_matches("").to_owned(), TYPES.to_owned()), + (ROOT.to_owned(), RELS.to_owned(), TYPES.replace("http://schemas.openxmlformats.org/package/2006/content-types", "wrong")), + ] { + assert!(read_xlsb(&package(&book(false), &sheet, &sst(), &roots, &rels, &types)).is_err()); + } + let chartsheet = RELS.replace( + "Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet", + ); + let (wb, report) = read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, &chartsheet, TYPES)).unwrap(); + assert_eq!(wb.sheets.len(), 1); + assert!(report.warnings.iter().any(|w| w.contains("non-worksheet"))); + // An unrelated binary part must not change the format of an XML workbook package. + let xml_book = TYPES.replace( + "/records/book.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main", + "/elsewhere.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main", + ); + assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &xml_book)), Format::Xlsx); + let spec_type = TYPES.replace("application/vnd.ms-excel.sheet.binary.macroEnabled.main", "application/vnd.ms-excel.main"); + assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)), Format::Xlsb); + assert!(read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)).is_ok()); +} + +#[test] +fn xlsb_declared_counts_and_decoded_text_are_bounded() { + let bad_sst = record(159, &[0xff; 8]); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err()); + let mut count_mismatch = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]); + count_mismatch.extend(record(160, &[])); + assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &count_mismatch, ROOT, RELS, TYPES)).is_err()); + let mut data = row(0); + let text = wide(&"x".repeat(32767)); + for col in 0..2050 { + data.extend(cell(6, col, &text)); + } + assert!(read_xlsb(&file(&worksheet(&data, &[]))).unwrap_err().to_string().contains("64 MiB")); +} + +#[test] +fn xlsb_phonetic_runs_use_documented_size_and_tolerate_padding() { + // [MS-XLSB] 2.5.103: a PhRun is 10 bytes. Phonetic data is not imported, so + // padding after the documented runs (e.g. 12-byte runs) is ignored, while + // a run shorter than documented is still rejected. + let phonetic = |runs: u32, bytes: usize| { + let mut out = vec![2]; + out.extend(wide("ruby")); + out.extend(wide("ルビ")); + out.extend(runs.to_le_bytes()); + out.extend(vec![0; bytes]); + out + }; + for padded in [phonetic(1, 10), phonetic(1, 12), phonetic(2, 24)] { + let (wb, _) = read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &padded)].concat(), &[]))).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("ruby")); + } + assert!(read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &phonetic(2, 12))].concat(), &[]))).is_err()); +} diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs new file mode 100644 index 0000000..897a40b --- /dev/null +++ b/crates/xlsx/src/xlsb.rs @@ -0,0 +1,673 @@ +//! Data-only XLSB import, implemented from Microsoft's public MS-XLSB specification: +//! . +//! Formula caches become constants. Styles, macros and other workbook features are not run or imported. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, Sheet, Visibility, Workbook}; +use quick_xml::NsReader; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; + +use crate::package::{Package, Rel, normalize, resolve, split_dir}; +use crate::{IoError, ReadReport}; + +const MAIN: &str = "application/vnd.ms-excel.main"; +const BINARY_MAIN: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.main"; +const WORKSHEET: &str = "application/vnd.ms-excel.worksheet"; +const STRINGS: &str = "application/vnd.ms-excel.sharedStrings"; +const CT_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/content-types"; +const REL_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/relationships"; +const REL_TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; +const STRICT_REL_TYPE: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/"; +const MS_REL_TYPE: &str = "http://schemas.microsoft.com/office/2006/relationships/"; +const MAX_META: u64 = 1024 * 1024; +const MAX_TEXT: usize = 64 * 1024 * 1024; +const MAX_CELLS: usize = 1_000_000; +const MAX_STRINGS: usize = 1_000_000; +const MAX_SHEETS: usize = 1024; +const MAX_RECORDS: usize = 5_000_000; + +fn invalid(msg: impl Into) -> IoError { + IoError::Format(format!("XLSB: {}", msg.into())) +} + +#[derive(Default)] +struct Budget { + cells: usize, + text: usize, +} +impl Budget { + fn text(&mut self, len: usize) -> Result<(), IoError> { + self.text = self.text.saturating_add(len); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("XLSB decoded text exceeds 64 MiB".into())); + } + Ok(()) + } + fn cell(&mut self) -> Result<(), IoError> { + self.cells += 1; + if self.cells > MAX_CELLS { + return Err(IoError::TooLarge("XLSB exceeds 1 million imported cells".into())); + } + Ok(()) + } +} + +/// Imports worksheet scalar values and saved formula results. Number formats are omitted; +/// dates remain serial numbers with the workbook's original date system. Inspect warnings. +pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let mut package = Package::open(bytes)?; + let types = content_types(&mut package)?; + let roots = relationships(&mut package, "")?; + let roots: Vec<_> = roots.iter().filter(|r| r.kind == "officeDocument").collect(); + if roots.len() != 1 { + return Err(invalid("expected one workbook relationship")); + } + let root = roots.first().ok_or_else(|| invalid("missing workbook relationship"))?; + if root.external { + return Err(invalid("external workbook relationship")); + } + check_type(&types, &root.target, MAIN)?; + let book = required(&mut package, &root.target)?; + let mut wb = Workbook::new(); + wb.sheets.clear(); + let mut report = ReadReport::default(); + report.warn("XLSB data-only import: formulas become their saved results and will not recalculate. Formatting, charts, images, macros and other features are not imported; dates, currency and percentages may display as numbers. Save a new .xlsx file; XLSB saving is not supported."); + let sheets = workbook(&book, &mut wb, &mut report)?; + let rels = relationships(&mut package, &root.target)?; + let mut budget = Budget::default(); + let sst_rels: Vec<_> = rels.iter().filter(|r| r.kind == "sharedStrings").collect(); + if sst_rels.len() > 1 { + return Err(invalid("multiple shared-string relationships")); + } + let strings = if let Some(rel) = sst_rels.first() { + if rel.external { + return Err(invalid("external shared-string relationship")); + } + check_type(&types, &rel.target, STRINGS)?; + shared_strings(&required(&mut package, &rel.target)?, &mut budget, &mut report)? + } else { + vec![] + }; + for info in sheets { + let Some(id) = info.rel else { + if info.visibility != Visibility::VeryHidden { + return Err(invalid("worksheet relationship is null")); + } + report.warn("XLSB module sheets were skipped."); + continue; + }; + let rel = rels.iter().find(|r| r.id == id).ok_or_else(|| invalid("worksheet relationship is missing"))?; + if rel.external { + return Err(invalid("external sheet relationship")); + } + if rel.kind != "worksheet" { + if !matches!(rel.kind.as_str(), "chartsheet" | "dialogsheet" | "xlMacrosheet" | "xlIntlMacrosheet") { + return Err(invalid("unknown sheet relationship type")); + } + report.warn("XLSB non-worksheet sheets were skipped."); + continue; + } + check_type(&types, &rel.target, WORKSHEET)?; + wb.check_sheet_name(&info.name, None).map_err(invalid)?; + let mut sheet = worksheet(&required(&mut package, &rel.target)?, &info.name, &strings, &mut budget, &mut report)?; + sheet.visibility = info.visibility; + wb.sheets.push(Arc::new(sheet)); + } + if wb.sheets.is_empty() { + return Err(invalid("no supported worksheets")); + } + wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + Ok((wb, report)) +} + +pub(crate) fn is_xlsb(bytes: &[u8]) -> bool { + let inspect = || -> Result { + let mut package = Package::open(bytes)?; + let types = content_types(&mut package)?; + let roots = relationships(&mut package, "")?; + let mut books = roots.iter().filter(|r| r.kind == "officeDocument"); + let Some(root) = books.next() else { return Ok(false) }; + Ok(books.next().is_none() && !root.external && check_type(&types, &root.target, MAIN).is_ok()) + }; + inspect().unwrap_or(false) +} + +fn required(package: &mut Package<'_>, path: &str) -> Result, IoError> { + package.read(path)?.ok_or_else(|| invalid(format!("missing part {path}"))) +} + +struct Entry { + name: String, + attrs: Vec<(String, String)>, +} +impl Entry { + fn attr(&self, name: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) + } +} + +/// The two package metadata parts are flat, namespace-qualified XML documents. Keep only +/// direct child entries, and reject truncated XML instead of guessing a partial relationship. +fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result, IoError> { + let mut reader = NsReader::from_reader(bytes); + let mut depth = 0usize; + let mut seen = false; + let mut entries = vec![]; + loop { + let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?; + match &event { + Event::Start(e) | Event::Empty(e) => { + let (namespace, local) = reader.resolve_element(e.name()); + let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns); + if depth == 0 { + if seen || !valid_ns || local.as_ref() != root_name { + return Err(invalid("invalid package metadata root")); + } + seen = true; + } else if depth == 1 && valid_ns { + let mut attrs = vec![]; + for a in e.attributes() { + let a = a.map_err(|e| IoError::Xml(e.to_string()))?; + if !a.key.as_ref().contains(&b':') { + attrs.push(( + String::from_utf8_lossy(a.key.as_ref()).into_owned(), + a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(), + )); + } + } + entries.push(Entry { name: String::from_utf8_lossy(local.as_ref()).into_owned(), attrs }); + } + if matches!(event, Event::Start(_)) { + depth += 1; + if depth > crate::xml::MAX_DEPTH { + return Err(invalid("package metadata is nested too deeply")); + } + } + } + Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?, + Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => { + return Err(invalid("text outside package metadata")); + } + Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")), + Event::Eof => break, + _ => {} + } + } + if !seen || depth != 0 { + return Err(invalid("incomplete package metadata")); + } + Ok(entries) +} + +fn content_types(package: &mut Package<'_>) -> Result, IoError> { + let data = package.read_limited("[Content_Types].xml", MAX_META)?.ok_or_else(|| invalid("missing content types"))?; + metadata(&data, CT_NS, b"Types") +} + +fn check_type(types: &[Entry], path: &str, expected: &str) -> Result<(), IoError> { + let found = types.iter().find(|e| e.name == "Override" && e.attr("PartName").is_some_and(|p| normalize(p) == normalize(path))).or_else(|| { + types.iter().find(|e| { + e.name == "Default" && e.attr("Extension").is_some_and(|ext| path.rsplit('.').next().is_some_and(|p| p.eq_ignore_ascii_case(ext))) + }) + }); + let actual = found.and_then(|e| e.attr("ContentType")); + if actual != Some(expected) && !(expected == MAIN && actual == Some(BINARY_MAIN)) { + return Err(invalid(format!("wrong or missing content type for {path}"))); + } + Ok(()) +} + +fn relationships(package: &mut Package<'_>, path: &str) -> Result, IoError> { + let (dir, file) = split_dir(path); + let relpath = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }; + let data = package.read_limited(&relpath, MAX_META)?.ok_or_else(|| invalid(format!("missing relationships for {path}")))?; + let mut out: Vec = vec![]; + for e in metadata(&data, REL_NS, b"Relationships")? { + if e.name != "Relationship" { + continue; + } + let id = e.attr("Id").filter(|s| !s.is_empty()).ok_or_else(|| invalid("relationship has no ID"))?; + if out.iter().any(|r| r.id == id) { + return Err(invalid("duplicate relationship ID")); + } + let ty = e.attr("Type").ok_or_else(|| invalid("relationship has no type"))?; + let kind = ty + .strip_prefix(REL_TYPE) + .or_else(|| ty.strip_prefix(STRICT_REL_TYPE)) + .or_else(|| ty.strip_prefix(MS_REL_TYPE)) + .map(str::to_owned) + .unwrap_or_else(|| format!("unsupported:{ty}")); + let target = e.attr("Target").ok_or_else(|| invalid("relationship has no target"))?; + let external = e.attr("TargetMode") == Some("External"); + out.push(Rel { id: id.into(), kind, target: if external { target.into() } else { resolve(dir, target) }, external }); + } + Ok(out) +} + +struct Bytes<'a> { + rest: &'a [u8], +} +impl<'a> Bytes<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { rest: bytes } + } + fn take(&mut self, n: usize) -> Result<&'a [u8], IoError> { + let out = self.rest.get(..n).ok_or_else(|| invalid("truncated record or string"))?; + self.rest = self.rest.get(n..).ok_or_else(|| invalid("truncated record"))?; + Ok(out) + } + fn u8(&mut self) -> Result { + self.take(1)?.first().copied().ok_or_else(|| invalid("truncated byte")) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.take(2)?.try_into().map_err(|_| invalid("truncated u16"))?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("truncated u32"))?)) + } + fn f64(&mut self) -> Result { + let n = f64::from_le_bytes(self.take(8)?.try_into().map_err(|_| invalid("truncated number"))?); + if !n.is_finite() { + return Err(invalid("nonfinite numeric value")); + } + Ok(n) + } + fn string(&mut self, max: u32, nullable: bool) -> Result, IoError> { + let count = self.u32()?; + if nullable && count == u32::MAX { + return Ok(None); + } + if count > max { + return Err(IoError::TooLarge("XLSB string length exceeds its limit".into())); + } + let size = (count as usize).checked_mul(2).ok_or_else(|| invalid("string length overflow"))?; + let bytes = self.take(size)?; + let mut text = String::new(); + for ch in char::decode_utf16(bytes.as_chunks::<2>().0.iter().copied().map(u16::from_le_bytes)) { + text.push(ch.map_err(|_| invalid("invalid UTF-16 string"))?); + } + Ok(Some(text)) + } + fn text(&mut self, max: u32) -> Result { + self.string(max, false)?.ok_or_else(|| invalid("null string")) + } + fn finish(self) -> Result<(), IoError> { + if !self.rest.is_empty() { + return Err(invalid("unexpected bytes after record payload")); + } + Ok(()) + } +} + +struct Records<'a> { + bytes: Bytes<'a>, + count: usize, + blocks: Vec, +} +impl<'a> Records<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes: Bytes::new(bytes), count: 0, blocks: vec![] } + } + fn next(&mut self, report: &mut ReadReport) -> Result, IoError> { + while !self.bytes.rest.is_empty() { + self.count += 1; + if self.count > MAX_RECORDS { + return Err(IoError::TooLarge("XLSB part contains too many records".into())); + } + let first = self.bytes.u8()?; + let id = if first & 0x80 == 0 { + first as u16 + } else { + let id = u16::from(first & 0x7f) | (u16::from(self.bytes.u8()? & 0x7f) << 7); + if id < 128 { + return Err(invalid("invalid two-byte record type")); + } + id + }; + let mut size = 0usize; + for shift in [0, 7, 14, 21] { + let b = self.bytes.u8()?; + size |= usize::from(b & 0x7f) << shift; + // The high bit of the fourth length byte is ignored by MS-XLSB. + if b & 0x80 == 0 || shift == 21 { + break; + } + } + let payload = self.bytes.take(size)?; + match id { + 35 | 37 => { + let mut b = Bytes::new(payload); + if id == 35 { + b.take(4)?; + } else { + let count = b.u16()?; + if count == 0 { + return Err(invalid("empty alternate-content version list")); + } + b.take(usize::from(count) * 4)?; + } + b.finish()?; + if self.blocks.len() >= 64 { + return Err(invalid("extension blocks nested too deeply")); + } + self.blocks.push(id + 1); + report.warn("Unsupported XLSB future-record and alternate-content blocks were skipped."); + } + 36 | 38 => { + if self.blocks.pop() != Some(id) || !payload.is_empty() { + return Err(invalid("mismatched extension block end")); + } + } + _ if !self.blocks.is_empty() => {} + _ => return Ok(Some((id, payload))), + } + } + if !self.blocks.is_empty() { + return Err(invalid("unterminated extension block")); + } + Ok(None) + } + fn first(&mut self, id: u16, report: &mut ReadReport) -> Result<(), IoError> { + if self.next(report)? != Some((id, &[])) { + return Err(invalid("missing part begin record")); + } + Ok(()) + } + fn end(&mut self, payload: &[u8], report: &mut ReadReport) -> Result<(), IoError> { + if !payload.is_empty() || self.next(report)?.is_some() { + return Err(invalid("records after part end")); + } + Ok(()) + } +} + +struct SheetInfo { + name: String, + rel: Option, + visibility: Visibility, +} +fn workbook(bytes: &[u8], wb: &mut Workbook, report: &mut ReadReport) -> Result, IoError> { + let mut records = Records::new(bytes); + records.first(131, report)?; + let mut sheets = vec![]; + let mut bundle = false; + let mut bundle_seen = false; + while let Some((id, payload)) = records.next(report)? { + let mut b = Bytes::new(payload); + match id { + 132 => { + if bundle || !bundle_seen { + return Err(invalid("incomplete worksheet list")); + } + records.end(payload, report)?; + return Ok(sheets); + } + 143 => { + if bundle_seen { + return Err(invalid("duplicate worksheet list")); + } + bundle_seen = true; + bundle = true; + b.finish()?; + } + 144 => { + if !bundle { + return Err(invalid("unexpected worksheet list end")); + } + bundle = false; + b.finish()?; + } + 153 => { + wb.date_system = if b.u32()? & 1 != 0 { DateSystem::D1904 } else { DateSystem::D1900 }; + b.u32()?; + b.text(31)?; + b.finish()?; + } + 156 => { + if !bundle { + return Err(invalid("worksheet outside the worksheet list")); + } + if sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("XLSB exceeds 1024 sheets".into())); + } + let visibility = match b.u32()? { + 0 => Visibility::Visible, + 1 => Visibility::Hidden, + 2 => Visibility::VeryHidden, + _ => return Err(invalid("invalid sheet visibility")), + }; + let sheet_id = b.u32()?; + if !(1..=65535).contains(&sheet_id) { + return Err(invalid("invalid worksheet ID")); + } + let rel = b.string(255, true)?; + let name = b.text(31)?; + b.finish()?; + sheets.push(SheetInfo { name, rel, visibility }); + } + _ => {} + } + } + Err(invalid("missing workbook end record")) +} + +fn rich_string(b: &mut Bytes<'_>) -> Result { + let flags = b.u8()?; + let text = b.text(32767)?; + if flags & 1 != 0 { + let count = b.u32()?; + if count > 32767 { + return Err(invalid("too many rich-text runs")); + } + b.take(count as usize * 4)?; + } + if flags & 2 != 0 { + b.text(32767)?; + let count = b.u32()?; + if count > 32767 { + return Err(invalid("too many phonetic runs")); + } + // [MS-XLSB] 2.5.103 PhRun: ichFirst, ichMom, cchMom, ifnt (u16 each) + // then phType/alcH/unused1 (16 bits) = 10 bytes. A RichStr is always + // the last field of its record (BrtSSTItem, BrtCellRichSt) and + // phonetic data is not imported, so tolerate writers that pad runs: + // require the documented size, then drop whatever follows. + b.take(count as usize * 10)?; + b.rest = &[]; + } + Ok(text) +} + +fn shared_strings(bytes: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result>, IoError> { + let mut records = Records::new(bytes); + let Some((159, payload)) = records.next(report)? else { + return Err(invalid("missing shared-string begin record")); + }; + let mut header = Bytes::new(payload); + let total = header.u32()?; + let unique = header.u32()?; + header.finish()?; + if unique > total || unique as usize > MAX_STRINGS { + return Err(IoError::TooLarge("invalid or oversized shared-string count".into())); + } + let mut strings = vec![]; + while let Some((id, payload)) = records.next(report)? { + match id { + 160 => { + records.end(payload, report)?; + if strings.len() != unique as usize { + return Err(invalid("shared-string count does not match records")); + } + return Ok(strings); + } + 19 => { + if strings.len() >= unique as usize { + return Err(invalid("too many shared-string items")); + } + let mut b = Bytes::new(payload); + let text = rich_string(&mut b)?; + b.finish()?; + budget.text(text.len())?; + strings.push(Arc::from(text)); + } + _ => return Err(invalid("unexpected shared-string record")), + } + } + Err(invalid("missing shared-string end record")) +} + +fn error(code: u8) -> Result { + Ok(match code { + 0x00 => CellError::Null, + 0x07 => CellError::Div0, + 0x0f => CellError::Value, + 0x17 => CellError::Ref, + 0x1d => CellError::Name, + 0x24 => CellError::Num, + 0x2a => CellError::NA, + 0x2b => CellError::GettingData, + _ => return Err(invalid("unknown cell error code")), + }) +} +fn boolean(b: &mut Bytes<'_>) -> Result { + match b.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(invalid("invalid boolean value")), + } +} +fn cached_formula(b: &mut Bytes<'_>) -> Result<(), IoError> { + b.take(2)?; + let cce = b.u32()?; + if !(1..=16384).contains(&cce) { + return Err(invalid("invalid formula token length")); + } + b.take(cce as usize)?; + let extra = b.u32()?; + b.take(extra as usize)?; + Ok(()) +} +fn value(id: u16, b: &mut Bytes<'_>, strings: &[Arc], budget: &mut Budget) -> Result { + let value = match id { + 1 => Value::Empty, + 2 => { + let rk = b.u32()?; + let n = if rk & 2 != 0 { ((rk as i32) >> 2) as f64 } else { f64::from_bits(u64::from(rk & !3) << 32) }; + let n = if rk & 1 != 0 { n / 100.0 } else { n }; + if !n.is_finite() { + return Err(invalid("nonfinite RK number")); + } + Value::Number(n) + } + 3 | 11 => Value::Error(error(b.u8()?)?), + 4 | 10 => Value::Bool(boolean(b)?), + 5 | 9 => Value::Number(b.f64()?), + 6 | 8 | 62 => { + let s = if id == 62 { rich_string(b)? } else { b.text(32767)? }; + budget.text(s.len())?; + Value::Text(Arc::from(s)) + } + 7 => Value::Text(strings.get(b.u32()? as usize).cloned().ok_or_else(|| invalid("shared-string index is out of bounds"))?), + _ => return Err(invalid("unsupported cell record")), + }; + if (8..=11).contains(&id) { + cached_formula(b)?; + } + Ok(value) +} + +fn worksheet(bytes: &[u8], name: &str, strings: &[Arc], budget: &mut Budget, report: &mut ReadReport) -> Result { + let mut records = Records::new(bytes); + records.first(129, report)?; + let mut sheet = Sheet::new(name); + let mut data = false; + let mut data_seen = false; + let mut row = None; + let mut col = None; + while let Some((id, payload)) = records.next(report)? { + let mut b = Bytes::new(payload); + match id { + 130 => { + if data || !data_seen { + return Err(invalid("incomplete worksheet data")); + } + records.end(payload, report)?; + return Ok(sheet); + } + 145 => { + if data_seen { + return Err(invalid("duplicate worksheet data")); + } + data_seen = true; + data = true; + b.finish()?; + } + 146 => { + if !data { + return Err(invalid("unexpected worksheet data end")); + } + data = false; + b.finish()?; + } + 0 => { + if !data { + return Err(invalid("row outside worksheet data")); + } + let r = b.u32()?; + if r >= MAX_ROWS || row.is_some_and(|previous| r <= previous) { + return Err(invalid("row is out of bounds or unordered")); + } + b.take(9)?; + let spans = b.u32()?; + if spans > 16 { + return Err(invalid("too many row spans")); + } + b.take(spans as usize * 8)?; + b.finish()?; + row = Some(r); + col = None; + } + 1..=11 | 62 => { + if !data { + return Err(invalid("cell outside worksheet data")); + } + let r = row.ok_or_else(|| invalid("cell before row header"))?; + let c = b.u32()?; + if c >= MAX_COLS || col.is_some_and(|previous| c <= previous) { + return Err(invalid("column is out of bounds or unordered")); + } + b.u32()?; // Style index and phonetic flag; formatting is not imported. + let value = value(id, &mut b, strings, budget)?; + b.finish()?; + if !value.is_empty() { + budget.cell()?; + sheet.cells.set(CellRef::new(r, c), Cell::value(value)); + } + col = Some(c); + } + 176 => { + if data { + return Err(invalid("merge inside worksheet data")); + } + let r0 = b.u32()?; + let r1 = b.u32()?; + let c0 = b.u32()?; + let c1 = b.u32()?; + b.finish()?; + if r0 > r1 || c0 > c1 || r1 >= MAX_ROWS || c1 >= MAX_COLS { + report.warn("Invalid XLSB merged ranges were skipped."); + continue; + } + if sheet.merges.len() >= 100_000 { + return Err(IoError::TooLarge("XLSB exceeds 100000 merges".into())); + } + sheet.merges.push(RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))); + } + _ => {} + } + } + Err(invalid("missing worksheet end record")) +} diff --git a/docs/cli.md b/docs/cli.md index 2cf5985..44e69e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,14 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved +values; source formatting and other workbook features are omitted. Import warnings go to stderr. +Convert to `.xlsx` to save the imported workbook; XLSB output is not supported. + +`.ods` files support [worksheet data import](ods-import.md). Formula cells use their last saved +values; source formatting and other workbook features are omitted. Import warnings go to stderr. +Convert to `.xlsx` to save the imported workbook; ODS output is not supported. + ```sh cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- ``` diff --git a/docs/ods-import.md b/docs/ods-import.md new file mode 100644 index 0000000..69d95cf --- /dev/null +++ b/docs/ods-import.md @@ -0,0 +1,22 @@ +# Importing OpenDocument spreadsheets + +Open an `.ods` file with File → Open, drag it onto GridCraft, or pass it to the CLI. GridCraft +imports worksheet data into a new workbook and shows a warning describing the conversion limits. + +The import reads sheets, numbers, text, booleans, supported dates/times, repeated cells/rows and +merged cells. Formula cells become their **last saved results**, not live formulas. These results +may be out of date if the source application did not recalculate before saving. Unsupported or +missing values retain available display text and produce a warning. + +This is data import, not full ODS compatibility. Source formatting, formulas, charts, images, +names and other workbook features are not preserved. GridCraft does not write ODS files. + +Save opens Save As and suggests a new `.xlsx` file. The source ODS file is not used as the +workbook's save destination; requests to save to `.ods` return an error. + +```sh +gridcraft-cli info source.ods +gridcraft-cli convert source.ods imported.xlsx +``` + +CLI import warnings go to stderr, leaving structured output on stdout available for scripts. diff --git a/docs/releasing.md b/docs/releasing.md index f4a28eb..0e0abfa 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,6 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re | Windows 11 ARM64 | `gridcraft--windows-arm64.msi`, `…-portable.zip` (cross-compiled, signed like x64) | `windows-latest`; installed and run on `windows-11-arm` by `windows-arm64.yml` | | Linux x86_64 | `gridcraft--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | diff --git a/docs/xlsb-import.md b/docs/xlsb-import.md new file mode 100644 index 0000000..f513d75 --- /dev/null +++ b/docs/xlsb-import.md @@ -0,0 +1,22 @@ +# Importing Excel binary workbooks + +Open an `.xlsb` file with File → Open, drag it onto GridCraft, or pass it to the CLI. +GridCraft imports worksheet data into a new workbook and shows the conversion limits. + +The import reads worksheet names, visibility, numbers, text, booleans, errors and merged +cells. Formula cells become their **last saved results**, not live formulas. Those results +may be out of date if the source application did not recalculate before saving. + +Source formatting is omitted: dates, times, percentages and currencies initially display as +their underlying numbers. The workbook's date system is retained. Charts, images, names, +macros and other workbook features are not imported. GridCraft does not write XLSB files. + +Save opens Save As and suggests a new `.xlsx` file. The source XLSB file is not used as the +workbook's save destination; requests to save to `.xlsb` return an error. + +```sh +gridcraft-cli info source.xlsb +gridcraft-cli convert source.xlsb imported.xlsx +``` + +CLI import warnings go to stderr, leaving structured output on stdout available for scripts. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20ad1b2..9fde2c5 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package GridCraft for Linux ( is x86_64 or aarch64): +# Build and package GridCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/gridcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/gridcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +43,13 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +57,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/gridcraft" "$STAGE/usr/bin/gridcraft" install -Dm755 "$BIN/gridcraft-cli" "$STAGE/usr/bin/gridcraft-cli" -strip "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/gridcraft" "$STAGE/usr/bin/gridcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +140,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/gridcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/gridcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version +fi echo "==> done" ls -lh "$DIST"
next")); + for content in ["12.5%", "25.0%", "font-weight:bold;", "font-style:italic;", "color:#123456;", "background:#FEDCBA;", "text-align:right;"] { + assert!(html.contains(content), "missing {content}"); + } + assert!(!html.contains("outside selection")); + assert!(!html.contains("x
merged
side