Merge updated #31 (latest main) into feature/i18n-zh-ko-ru
This commit is contained in:
commit
d51d4e51b9
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -84,6 +84,8 @@
|
|||
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
||||
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
||||
PivotTables and print settings round-trip. CSV and TSV too.
|
||||
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
|
||||
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
|
||||
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
||||
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
||||
structured table references, Excel's full number-format language, sort and AutoFilter,
|
||||
|
|
@ -115,6 +117,29 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa
|
|||
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
browser's file picker (dropping files works too) and Save downloads the workbook.
|
||||
|
||||
### Ribbon access keys
|
||||
|
||||
Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
|
||||
then type a sequence below one key at a time. Keytips appear on the supported Home
|
||||
controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option
|
||||
remains available for entering accented characters.
|
||||
|
||||
| Sequence after Alt/F10 | Action |
|
||||
|---|---|
|
||||
| `H A L` / `H A C` / `H A R` | Align left / center / right |
|
||||
| `H V S` or legacy `E S` | Open Paste Special |
|
||||
| `H O I` or legacy `O C A` | AutoFit selected columns |
|
||||
|
||||
In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles
|
||||
Transpose. **Enter** applies the selected options; **Escape** cancels the dialog.
|
||||
These letters also work with Alt held on Windows/Linux/BSD.
|
||||
|
||||
Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling,
|
||||
or switching windows cancels navigation. Access keys do not start while editing text
|
||||
or using another dialog. This is initial support for these Home and legacy paths;
|
||||
other ribbon commands and full mouse-free navigation remain to be implemented.
|
||||
Browsers or desktop environments may reserve Alt/F10 for their own menus.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder
|
||||
|
|
@ -154,7 +179,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
|||
|---|---|
|
||||
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
|
||||
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
|
||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) |
|
||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
|
||||
| L3 | `chart` (toolkit-free chart layout and rendering) |
|
||||
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
|
||||
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ impl log::Log for AppLogger {
|
|||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true }
|
|||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
css.push_str("font-weight:bold;");
|
||||
}
|
||||
if st.font.italic {
|
||||
css.push_str("font-style:italic;");
|
||||
}
|
||||
if let Some(h) = st.font.color.hex(&wb.theme) {
|
||||
let _ = write!(css, "color:{h};");
|
||||
}
|
||||
if st.fill.pattern != gridcraft_model::PatternType::None
|
||||
&& let Some(h) = st.fill.fg.hex(&wb.theme)
|
||||
{
|
||||
let _ = write!(css, "background:{h};");
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
css
|
||||
}
|
||||
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// An HTML fragment for the same range as `plain_text`, previously produced by
|
||||
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
|
||||
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<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("&"),
|
||||
'<' => out.write_str("<"),
|
||||
'>' => out.write_str(">"),
|
||||
'"' => out.write_str("""),
|
||||
'\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()
|
||||
|
|
|
|||
|
|
@ -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("<tag>&"<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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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!({})),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
|
@ -201,6 +234,23 @@ impl SheetApp {
|
|||
r
|
||||
}
|
||||
|
||||
/// Copy or cut the selected cells to the system clipboard. Engine-only commands keep
|
||||
/// their existing internal clipboard behavior and do not write to the host clipboard.
|
||||
pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) {
|
||||
// Only ask the engine for HTML when the host can publish it.
|
||||
let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return };
|
||||
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
|
||||
if let Some(html) = result.get("html").and_then(Json::as_str)
|
||||
&& let Some(copy_html) = &mut self.services.copy_html
|
||||
{
|
||||
match copy_html(html, text) {
|
||||
Ok(()) => return,
|
||||
Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"),
|
||||
}
|
||||
}
|
||||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
|
||||
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
|
||||
pub fn run_or_alert(&mut self, id: &str, params: Json) {
|
||||
if let Err(e) = self.run(id, params) {
|
||||
|
|
@ -295,6 +345,10 @@ impl SheetApp {
|
|||
}
|
||||
|
||||
pub fn open_dialog(&mut self, name: &str, params: Json) {
|
||||
if let Err(e) = self.commit_text_box_edit() {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
match name {
|
||||
"open" => {
|
||||
if let Some(start) = &self.services.open_async {
|
||||
|
|
@ -325,7 +379,7 @@ impl SheetApp {
|
|||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match self.session.run("file.open", json!({"path": path})) {
|
||||
match self.run("file.open", json!({"path": path})) {
|
||||
Ok(_) => {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
|
|
@ -351,6 +405,10 @@ impl SheetApp {
|
|||
|
||||
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
|
||||
pub fn begin_edit(&mut self, text: Option<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"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
@ -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, "é");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven!<![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 & 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(_))));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue