Merge updated #31 (latest main) into feature/i18n-zh-ko-ru

This commit is contained in:
Brandon Thomas 2026-10-10 13:15:11 -04:00
commit d51d4e51b9
48 changed files with 4837 additions and 163 deletions

176
.github/workflows/flatpak-pages.yml vendored Normal file
View File

@ -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" <<META
[Flatpak Repo]
Title=GridCraft
Comment=Official GridCraft releases
Description=Official Flatpak repository for GridCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
flatpak install gridcraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
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

View File

@ -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-<v>-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'

89
Cargo.lock generated
View File

@ -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"

View File

@ -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) |

View File

@ -147,6 +147,14 @@ impl Args {
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
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
}

View File

@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "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"] }

View File

@ -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:?}");

View File

@ -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 }

View File

@ -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(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {

View File

@ -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<arboard::Clipboard> = 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()

View File

@ -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!({}));
}

View File

@ -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" },
]

View File

@ -62,6 +62,11 @@ pub trait Host {
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
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<RangeRef> {
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 }
}

View File

@ -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<T> {
/// Sorted, without duplicates.
items: Vec<(u32, u32, T)>,
dead: Vec<bool>,
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<u32>,
/// Inserted since the last merge.
pending: BTreeSet<(u32, u32, T)>,
}
impl<T> Default for Intervals<T> {
fn default() -> Self {
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
}
}
impl<T: Copy + Ord> Intervals<T> {
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<Key, Node>,
/// single cell → dependents
cell_deps: HashMap<Key, Vec<Key>>,
/// (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<Key, HashSet<Key>>,
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
col_deps: HashMap<(usize, u32), Intervals<Key>>,
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
dynamic: HashSet<Key>,
/// Range indexes changed since the last [`Graph::settle`].
unsettled_cols: HashSet<(usize, u32)>,
unsettled_wide: HashSet<usize>,
}
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<Key>, 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<Key>) {
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<Key>) {
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<Key>,
/// New spills found during the pass: anchor → array.
spills: HashMap<Key, std::sync::Arc<Array>>,
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
/// every row, so once per sheet rather than once per range read.
used: HashMap<usize, Option<RangeRef>>,
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<RangeRef> {
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<Key> = 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<u32>, a: u32, b: u32) -> Vec<u32> {
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<u32> = 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<u32> = 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<u32> = 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<u32> = 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<Key> = (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<Key> = (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::<u32>()))));
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")]);
}
}

View File

@ -275,6 +275,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
/// 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"))]
{

View File

@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true }
gridcraft-chart = { workspace = true }
gridcraft-pdf = { workspace = true }
image = { workspace = true }
[dev-dependencies]
zip = { workspace = true }

View File

@ -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;

View File

@ -40,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {

View File

@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {
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::<Vec<_>>().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::<Vec<_>>(), "warnings": warnings}),

View File

@ -122,6 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
})
}
/// 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<Json> {
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<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();

View File

@ -220,8 +220,10 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
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<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
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<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
} 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<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
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);

View File

@ -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)]

View File

@ -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<String>)> {
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<Vec<u8>> {
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
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<String> {
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("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.write_str("<tr>").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, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").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("&amp;"),
'<' => out.write_str("&lt;"),
'>' => out.write_str("&gt;"),
'"' => out.write_str("&quot;"),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.write_str("<br>")
}
'\n' => out.write_str("<br>"),
_ => out.write_char(ch),
}
.ok()?;
}
out.write_str("</td>").ok()?;
}
out.write_str("</tr>").ok()?;
}
out.write_str("</table>").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()

View File

@ -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 & <text>".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 & <escaped>\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, "<tag>&\"\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\"<tag>&\"\"\nnext\"\n25.0%\tlast\n");
assert!(html.starts_with("<table "));
assert!(html.ends_with("</table>"));
assert_eq!(html.matches("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 4);
assert!(html.contains("&lt;tag&gt;&amp;&quot;<br>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("<html"));
let cut = s.execute("edit.cut", json!({"range": "B2:C3", "html": true})).unwrap();
assert_eq!(cut["html"], copied["html"]);
assert_eq!(cut["text"], copied["text"]);
s.execute("edit.paste", json!({"at": "E5", "text": cut["text"]})).unwrap();
assert_eq!(v(&s, "E5"), Value::Number(0.125));
assert_eq!(v(&s, "B2"), Value::Empty);
}
#[test]
fn copy_and_cut_return_html_only_when_requested() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
for cmd in ["edit.copy", "edit.cut"] {
for params in [json!({"range": "A1"}), json!({"range": "A1", "html": false})] {
let r = s.execute(cmd, params).unwrap();
assert_eq!(r["text"], "x\n");
assert!(r.get("html").is_none(), "{cmd} must not bloat programmatic responses with HTML");
}
let r = s.execute(cmd, json!({"range": "A1", "html": true})).unwrap();
assert!(r["html"].as_str().is_some_and(|h| h.contains(">x</td>")));
}
}
#[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("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 1);
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
assert!(html.contains(">merged</td>"));
// 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("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 3);
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
assert!(!html.contains("merged"));
assert!(html.contains(">side</td>"));
}
#[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<u8> {
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#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>"#,
),
(
"content.xml",
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" office:version="1.3"><office:body><office:spreadsheet><table:table table:name="Data"><table:table-row><table:table-cell office:value-type="float" office:value="42" table:formula="of:=SUM([.B1:.B2])"/><table:table-cell office:value-type="string"><text:p>Original data</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#,
),
] {
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<u8> {
use std::io::{Cursor, Write};
fn wide(s: &str) -> Vec<u8> {
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<u8>, 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#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/xl/workbook.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/><Override PartName="/xl/worksheets/sheet1.bin" ContentType="application/vnd.ms-excel.worksheet"/></Types>"#;
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.bin"/></Relationships>"#;
let book_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.bin"/></Relationships>"#;
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());
}

View File

@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
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,
),

View File

@ -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);

View File

@ -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| {

View File

@ -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!({})),
}

View File

@ -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<String>,
alt_armed: bool,
// Keep consuming a held mnemonic after its action closes keytips.
held: Vec<Key>,
}
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<Action> {
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<Action>) {
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);
}
}

View File

@ -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<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
pub open_url: Option<Box<dyn Fn(&str)>>,
/// 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<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// 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<editor::EditState>,
pub text_box_editor: Option<text_box::EditState>,
pub dialog: Option<dialogs::Dialog>,
pub message: Option<(String, String)>,
pub toast: Option<(String, f64)>,
@ -143,6 +149,8 @@ pub struct SheetApp {
fonts_han: Option<theme::HanOrder>,
effective_dark: bool,
pub name_box: Option<String>,
/// 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<u32>,
@ -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<Json, String> {
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, &params) {
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<String>, 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"))]
{

View File

@ -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::<bool>(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,

View File

@ -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<Rect>,
}
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!({}));
}
}

View File

@ -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("<table"));
assert!(calls[0].0.contains("Ada"));
assert_eq!(calls[0].1, "Name\tScore\nAda\t42\n");
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.cut), Some(matches!(event, Event::Cut)));
}
}
#[test]
fn missing_or_failed_rich_clipboard_falls_back_to_plain_text() {
for fail in [false, true] {
let mut services = Services::default();
if fail {
services.copy_html = Some(Box::new(|_, _| Err("clipboard unavailable".into())));
}
let mut h = harness(services);
h.input_mut().events.push(Event::Copy);
h.step();
assert!(
h.output().platform_output.commands.iter().any(|c| { matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n") })
);
}
}
#[test]
fn programmatic_copy_does_not_write_to_the_system_clipboard() {
let calls = Rc::new(RefCell::new(0));
let received = calls.clone();
let mut h = harness(Services {
copy_html: Some(Box::new(move |_, _| {
*received.borrow_mut() += 1;
Ok(())
})),
..Default::default()
});
let copied = h.state_mut().run("edit.copy", json!({})).unwrap();
assert!(copied.get("html").is_none(), "HTML is only built when requested");
let copied = h.state_mut().run("edit.copy", json!({"html": true})).unwrap();
assert!(copied["html"].is_string());
assert_eq!(*calls.borrow(), 0);
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
}

View File

@ -0,0 +1,320 @@
//! Ribbon access keys through the complete egui input and UI path.
use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::HAlign;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
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<Item = Event>) {
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, "é");
}

View File

@ -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);
}

View File

@ -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;

659
crates/xlsx/src/ods.rs Normal file
View File

@ -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:
//! <https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part3-schema/OpenDocument-v1.3-os-part3-schema.html>.
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<Option<String>, 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<String>, default: u32, limit: u32) -> Result<u32, IoError> {
let n = match value {
Some(s) => s.parse::<u32>().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<RepeatedCell>,
}
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<String>,
raw: Option<String>,
formula: bool,
text: String,
paragraph: Option<usize>,
paragraphs: usize,
paragraph_started: bool,
pending_space: bool,
}
struct Import {
wb: Workbook,
report: ReadReport,
stack: Vec<Tag>,
sheet: Option<SheetDraft>,
row: Option<RowDraft>,
cell: Option<CellDraft>,
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::<u32>()).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::<f64>().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<f64> {
match s.split_once('.') {
Some((a, b)) => {
digits(a)?;
digits(b)?;
}
None => {
digits(s)?;
}
}
s.parse::<f64>().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<f64> {
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::<f64>().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::<f64>().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 })
}

View File

@ -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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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;

View File

@ -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);

View File

@ -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;

View File

@ -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#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>"#;
fn content(tables: &str) -> String {
format!(
r#"<?xml version="1.0"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"><office:body><office:spreadsheet>{tables}</office:spreadsheet></office:body></office:document-content>"#
)
}
fn ods(tables: &str) -> Vec<u8> {
make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", content(tables).as_bytes())])
}
fn row(cells: &str) -> Vec<u8> {
ods(&format!(r#"<table:table table:name="Data"><table:table-row>{cells}</table:table-row></table:table>"#))
}
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#"
<table:table-cell office:value-type="float" office:value="42.5"/>
<table:table-cell office:value-type="boolean" office:boolean-value="true"/>
<table:table-cell office:value-type="percentage" office:value="0.25"/>
<table:table-cell office:value-type="currency" office:currency="USD" office:value="19.99"/>
<table:table-cell office:value-type="string" office:string-value="literal =1+1"/>
<table:table-cell table:formula="of:=A1*2" office:value-type="float" office:value="85"><text:p>85</text:p></table:table-cell>
<table:table-cell table:formula="of:=1/0" office:value-type="string"><text:p>#DIV/0!</text:p></table:table-cell>
<table:table-cell table:formula="of:=UNKNOWN()"/>
<table:table-cell office:value-type="float" office:value="NaN"><text:p>unavailable</text:p></table:table-cell>
<table:table-cell office:value-type="boolean" office:boolean-value="0"/>
"#);
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#"<table:table-cell office:value-type="string"><text:p>one <text:span>two &amp; three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven&#33;<![CDATA[<eight>]]></text:p><text:p>nine</text:p><office:annotation><text:p>not cell text</text:p></office:annotation></table:table-cell>"#,
);
let (wb, _) = read_ods(&bytes).unwrap();
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!<eight>\nnine"));
let bytes = row(r#"<table:table-cell office:value-type="string"><text:p> one
<text:span> two </text:span> three <text:s text:c="0"/>four<text:s text:c="2"/> </text:p><text:p><text:s/> five </text:p></table:table-cell>"#);
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#"
<table:table table:name="First &amp; Second">
<table:table-header-rows><table:table-row><table:table-cell table:number-columns-spanned="2" table:number-rows-spanned="2" office:value-type="string"><text:p>merged</text:p></table:table-cell><table:covered-table-cell office:value-type="float" office:value="99"/></table:table-row></table:table-header-rows>
<table:table-row><table:covered-table-cell table:number-columns-repeated="2"/></table:table-row>
<table:table-row-group><table:table-row table:number-rows-repeated="2"><table:table-cell table:number-columns-repeated="3" office:value-type="float" office:value="7"/></table:table-row></table:table-row-group>
<table:table-row table:number-rows-repeated="1048572"><table:table-cell table:number-columns-repeated="16384"/></table:table-row>
</table:table>
<table:table table:name="Another"><table:table-row><table:table-cell office:value-type="string" office:string-value="second sheet"/></table:table-row></table:table>
"#);
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#"
<table:table-cell office:value-type="date" office:date-value="1900-02-28"/>
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:30:00.125"/>
<table:table-cell office:value-type="time" office:time-value="P1DT2H3M4.5S"/>
<table:table-cell office:value-type="time" office:time-value="-PT2H"/>
<table:table-cell office:value-type="date" office:date-value="1900-02-29"/>
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:00:00Z"/>
<table:table-cell office:value-type="time" office:time-value="P1M"/>
<table:table-cell office:value-type="time" office:time-value="PT2M1H"/>
"#);
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#"<table:table table:name="Actual"><table:table-row><table:table-cell office:value-type="float" office:value="9"/></table:table-row></table:table>"#)
.replace("<office:", "<o:").replace("</office:", "</o:").replace(" office:", " o:").replace("xmlns:office=", "xmlns:o=")
.replace("<table:", "<t:").replace("</table:", "</t:").replace(" table:", " t:").replace("xmlns:table=", "xmlns:t=");
let bytes = make_zip(&[("mimetype", MIME), ("content.xml", xml.as_bytes())]);
assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), Value::Number(9.0));
let spoofed = xml.replace("urn:oasis:names:tc:opendocument:xmlns:table:1.0", "wrong-namespace");
assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", spoofed.as_bytes())])).is_err());
let bytes =
row(r#"<table:table-cell xmlns:fake="wrong-namespace" fake:value-type="float" fake:value="123"><text:p>kept</text:p></table:table-cell>"#);
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#"<table:table table:name="Data"/>"#);
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#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:encryption-data/></manifest:manifest>"#;
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("</office:document-content>").to_owned(),
xml.replace("</office:spreadsheet>", "</office:wrong>"),
"<root/>".to_owned(),
format!("{xml}{xml}"),
] {
assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", bad.as_bytes())])).is_err(), "{bad}");
}
let deep = content(&format!("{}{}", "<x>".repeat(300), "</x>".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#"<table:table-cell table:number-columns-repeated="4294967296"/>"#,
r#"<table:table-cell table:number-columns-repeated="4294967295" office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="0"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell table:number-columns-spanned="2"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384" table:number-columns-spanned="2" office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell office:value-type="string"><text:p><text:s text:c="4294967295"/></text:p></table:table-cell>"#,
] {
assert!(read_ods(&row(cells)).is_err(), "{cells}");
}
let cells = ods(
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
);
assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_))));
let text = format!(
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="100000"><table:table-cell office:value-type="string" office:string-value="{}"/></table:table-row></table:table>"#,
"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#"<table:table table:name="{long}"/><table:table table:name="{long}"/><table:table table:name="'History'"/><table:table table:name="[]:*?"/>"#
));
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#"<table:table-cell xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" calcext:value-type="error" office:value-type="float" office:value="0" table:formula="of:=1/0"><text:p>#DIV/0!</text:p></table:table-cell>"#,
);
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#"<table:table table:name="Data">
<table:table-row><table:table-cell office:value-type="float" office:value="1"/><table:table-cell table:number-columns-repeated="4294967295"/><table:table-cell/></table:table-row>
<table:table-row table:number-rows-repeated="16777215"><table:table-cell table:number-columns-repeated="16777216"/></table:table-row>
<table:table-row table:number-rows-repeated="5"><table:table-cell/></table:table-row>
</table:table>"#);
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#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell/></table:table-row><table:table-row><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
);
assert!(matches!(read_ods(&late), Err(IoError::TooLarge(_))));
}

View File

@ -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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="book" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="records/book.bin"/></Relationships>"#;
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="one" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/one.bin"/><Relationship Id="two" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/two.bin"/><Relationship Id="strings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="../text/strings.bin"/></Relationships>"#;
const TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/records/book.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/><Override PartName="/data/one.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/data/two.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/text/strings.bin" ContentType="application/vnd.ms-excel.sharedStrings"/></Types>"#;
fn record(id: u16, bytes: &[u8]) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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("</Relationships>").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());
}

673
crates/xlsx/src/xlsb.rs Normal file
View File

@ -0,0 +1,673 @@
//! Data-only XLSB import, implemented from Microsoft's public MS-XLSB specification:
//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xlsb/acc8aa92-1f02-4167-99f5-84f9f676b95a>.
//! 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<String>) -> 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<bool, IoError> {
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<Vec<u8>, 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<Vec<Entry>, 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<Vec<Entry>, 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<Vec<Rel>, 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<Rel> = 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<u8, IoError> {
self.take(1)?.first().copied().ok_or_else(|| invalid("truncated byte"))
}
fn u16(&mut self) -> Result<u16, IoError> {
Ok(u16::from_le_bytes(self.take(2)?.try_into().map_err(|_| invalid("truncated u16"))?))
}
fn u32(&mut self) -> Result<u32, IoError> {
Ok(u32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("truncated u32"))?))
}
fn f64(&mut self) -> Result<f64, IoError> {
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<Option<String>, 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<String, IoError> {
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<u16>,
}
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<Option<(u16, &'a [u8])>, 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<String>,
visibility: Visibility,
}
fn workbook(bytes: &[u8], wb: &mut Workbook, report: &mut ReadReport) -> Result<Vec<SheetInfo>, 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<String, IoError> {
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<Vec<Arc<str>>, 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<CellError, IoError> {
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<bool, IoError> {
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<str>], budget: &mut Budget) -> Result<Value, IoError> {
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<str>], budget: &mut Budget, report: &mut ReadReport) -> Result<Sheet, IoError> {
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"))
}

View File

@ -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 -- <subcommand>
```

22
docs/ods-import.md Normal file
View File

@ -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.

View File

@ -82,6 +82,7 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re
| Windows 11 ARM64 | `gridcraft-<v>-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-<v>-linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `gridcraft-<v>-linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux riscv64 | `gridcraft-<v>-linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` |
| Flatpak x86_64, aarch64 | `gridcraft-<v>-linux-<arch>.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` |
| FreeBSD 14 x86_64 | `gridcraft-<v>-freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) |
| Web | `gridcraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |

22
docs/xlsb-import.md Normal file
View File

@ -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.

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package GridCraft for Linux (<arch> is x86_64 or aarch64):
# Build and package GridCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/gridcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/gridcraft-<version>-linux-<arch>.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"