diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 056cb05..bb3989d 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,78 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/gridcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - desktop-shortcut: - name: Windows desktop shortcut smoke - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Build fixture MSIs and test shortcut lifecycle - shell: pwsh - run: | - dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' } - $env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH" - ./packaging/windows/test-desktop-shortcut.ps1 - - name: Capture native MSI dialog previews - shell: pwsh - run: | - pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 ` - -Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" ` - -OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs" - if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' } - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: always() - with: - name: desktop-shortcut-msi-evidence - path: | - ${{ runner.temp }}/gridcraft-shortcut-smoke/*.log - ${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/* - if-no-files-found: ignore - retention-days: 7 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9050a..87d8954 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "GridCraft v". See docs/releasing.md. -# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is -# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run: -# they build and upload their artifacts, the environment refuses the signing jobs, and the -# draft-release job (which needs them) is skipped. name: Release on: @@ -21,431 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job needs no secrets (see the header about dry runs). - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### GridCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - 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: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - 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: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - 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 - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/gridcraft$|metainfo|applications' - ldd target/release/gridcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # 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 }} - TRUNK_VERSION: 0.21.14 - 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: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} - GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - # FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true, - # uploads the release-freebsd artifact (gridcraft--freebsd-x86_64.tar.gz). It needs no - # secrets, so the called workflow runs without the release environment (and in dry runs). - freebsd: - name: FreeBSD - needs: version - uses: ./.github/workflows/freebsd.yml - with: - version: ${{ needs.version.outputs.version }} - version_override: ${{ inputs.version != '' }} - package: true - release: - name: Draft GitHub Release - 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' - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="GridCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: gridcraft + app-title: GridCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 92239fa..2f9b141 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -17,71 +17,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - if: github.ref == 'refs/heads/release' - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: gridcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: gridcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'GridCraft' - foreach ($exe in 'gridcraft.exe', 'gridcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'gridcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "gridcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok gridcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'gridcraft.exe')) { throw 'uninstall left gridcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: gridcraft + app-dir: GridCraft diff --git a/Cargo.lock b/Cargo.lock index f772f83..99ec458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -697,6 +706,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -746,6 +764,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -776,6 +803,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1427,6 +1464,7 @@ dependencies = [ "image", "log", "muda", + "pollster 1.0.1", "rfd", "serde_json", "winresource", @@ -1529,6 +1567,7 @@ dependencies = [ "gridcraft-formula", "serde", "serde_json", + "sha2", "thiserror 2.0.21", ] @@ -1555,6 +1594,7 @@ dependencies = [ "log", "serde", "serde_json", + "skrifa", "sys-locale", "toml", "web-sys", @@ -1584,7 +1624,7 @@ dependencies = [ "gridcraft-core", "gridcraft-formula", "gridcraft-model", - "quick-xml 0.38.4", + "quick-xml", "thiserror 2.0.21", "zip", ] @@ -1663,6 +1703,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "image" version = "0.25.10" @@ -2480,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2739,15 +2788,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -3071,6 +3111,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3452,6 +3503,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3725,7 +3782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index d7fad0f..eab2853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,8 +32,9 @@ serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } +sha2 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui @@ -45,6 +46,9 @@ rfd = "0.17" arboard = { version = "3.6", default-features = false } # The desktop's preferred language, for the first-run interface language (native only). sys-locale = "0.3" +# Font parser egui's epaint uses; the UI validates system font files with it before handing them +# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy. +skrifa = { version = "0.44", default-features = false, features = ["std"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 770b3fe..72a1734 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 501 of Excel's 523 worksheet functions +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions ([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, @@ -230,8 +230,16 @@ $env:WGPU_BACKEND = "vulkan" Remove-Item Env:WGPU_BACKEND # restore the default for later launches ``` -An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux -and web backend defaults are unchanged. +An explicit `gl` override can bring the driver crash back on affected systems, and `vulkan` can +bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The macOS, Linux and +web backend defaults are unchanged. + +**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no +graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every +frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if +the first renderer fails to start, it tries the other one once. The log file records the choice +(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is +tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`. ### macOS diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..d44c743 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -62,7 +62,7 @@ collaboration, mobile, AI). | File formats | ~50% | 80–120 | [file-format-parity.md](docs/file-format-parity.md) | | Performance | ~35% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#performance) | | Stability | ~60% | 15–25 | [gaps.md](docs/gaps.md) | -| Localization | ~10% (ribbon in 5 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) | +| Localization | ~10% (ribbon in 6 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) | | Hardware (multi-core calc, GPU, pen, multi-window) | ~40% | 25–40 | [hardware-parity.md](docs/hardware-parity.md) | | Platforms | ~80% (no iOS/Android; ahead on Linux, BSD, RISC-V) | 30–60 | [target-app-parity.md](docs/target-app-parity.md#platforms) | | Ecosystem / add-ins | ~10% (VBA out of scope; own scripts, MCP, CLI) | 25–40 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) | @@ -74,7 +74,7 @@ collaboration, mobile, AI). | Area | Breadth | Ready | Hours | Doc | |---|---|---|---|---| | Formula language and calculation | ~92% | ~72% | 20–30 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Worksheet functions | 95.8% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) | +| Worksheet functions | 96.6% measured | ~82% | 16–26 | [function-parity.md](docs/function-parity.md) | | Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) | | Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | @@ -89,11 +89,12 @@ collaboration, mobile, AI). ## Languages -The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese and -Russian (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82), the +The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese, Russian +and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German +added 10-11), the interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts -(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. German -is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). +(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. +Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Language | Code | Status | UI strings | |---|---|---|---| @@ -106,11 +107,11 @@ is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Portuguese | pt-BR | menus only | ~12% | | Indonesian | id | none | 0% | | Japanese | ja | menus only | ~12% | -| German | de | none (PR #163 open) | 0% | +| German | de | menus only | ~12% | | Korean | ko | menus only | ~12% | | Vietnamese | vi | none | 0% | -Other shipped languages: 1 (Russian, menus only, ~12%). +Other shipped languages: 2 (Russian and German, menus only, ~12%). ## Upcoming @@ -131,6 +132,24 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) | +| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | +| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) | +| 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) | +| 2026-10-11 | BAHTTEXT: amounts in Thai words with Baht and Satang (#291) | +| 2026-10-11 | TRIMRANGE: ranges and arrays without blank outer rows and columns (#296) | +| 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) | +| 2026-10-11 | Ribbon translated into German (266 labels; `de`, `de-DE`, `de-AT`, `de-CH` locale tags resolve to German) | +| 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) | +| 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) | +| 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) | +| 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) | +| 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) | +| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) | +| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) | +| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) | +| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | +| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -142,6 +161,16 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | +| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | +| 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row | +| 2026-10-11 | minor | Worksheet functions 502 / 523 (TRIMRANGE); progress log row | +| 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) | +| 2026-10-11 | minor | Catalog 258 → 260 / 290: View › Formula Bar and Zoom 100% (#289) | +| 2026-10-11 | minor | Progress log: XLSX threaded comments round trip (#300) | +| 2026-10-11 | minor | Catalog 258 → 259 / 290: Allow Edit Ranges (#307) | +| 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row | +| 2026-10-11 | minor | Czech (cs) listed under other shipped languages: ribbon only, ~12% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 5cf5e07..94dc2b0 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -30,6 +30,10 @@ arboard = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] arboard = { workspace = true, features = ["wayland-data-control"] } +[target.'cfg(target_os = "windows")'.dependencies] +# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe. +pollster = "1" + [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } # Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a diff --git a/apps/gridcraft/src/graphics.rs b/apps/gridcraft/src/graphics.rs index ccd0c45..14344b4 100644 --- a/apps/gridcraft/src/graphics.rs +++ b/apps/gridcraft/src/graphics.rs @@ -1,47 +1,192 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! +//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters +//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access +//! violation happens in the driver, where no Rust error handling can catch it. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, DeviceType}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 - // workaround without mutating process environment variables. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); +/// What the start-up probe learned about one DirectX 12 adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Dx12Adapter { + /// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the + /// processor, which makes the app lag on older machines. + pub device_type: DeviceType, + /// Whether the adapter offers the limits eframe will ask for when it creates the device. + pub meets_limits: bool, +} + +/// The backends to initialize on Windows. +/// +/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always +/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found) +/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose +/// driver has no DirectX 12 support still have an OpenGL driver, and GL can't reach the AMD driver +/// crash described in [`configure_backends`], because an AMD GPU with a driver shows up as a +/// DirectX 12 adapter and keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays +/// enabled next to GL as a last resort for machines without an OpenGL driver either (virtual +/// machines): wgpu prefers any GPU over a software adapter, so it's only picked when GL has +/// nothing better. Vulkan is never part of the default. +pub fn choose_backends(backend_override: Option, dx12_adapters: &[Dx12Adapter]) -> Backends { + if let Some(backends) = backend_override { + return backends; } + let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu; + if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) { + Backends::DX12 + } else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) { + Backends::DX12 | Backends::GL + } else { + // No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a + // DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one. + Backends::GL + } +} + +/// The backends for a second start when the first one failed with a graphics error before the +/// app was created (no adapter, no device, no surface): the other one of DirectX 12 and OpenGL. +/// `None` when `WGPU_BACKEND` chose the backends (the user asked for exactly those) or the first +/// start already tried both. Never Vulkan (#120). +pub fn fallback_backends(backend_override: Option, first: Backends) -> Option { + if backend_override.is_some() { + return None; + } + let rest = (Backends::DX12 | Backends::GL).difference(first); + (!rest.is_empty()).then_some(rest) +} + +/// Restricts the wgpu instance eframe creates to `backends`. +pub fn configure_backends(options: &mut NativeOptions, backends: Backends) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's + // atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before + // adapter selection or any Rust error handling runs, so the window never appears. + // Choosing an adapter afterwards is too late: only leaving the backend out of the + // instance avoids it. This matches the verified WGPU_BACKEND=dx12 workaround without + // mutating process environment variables. + create.instance_descriptor.backends = backends; + } +} + +/// Chooses the backends ([`choose_backends`]), applies them to `options` and returns them. +/// Without a `backend_override` this first probes DirectX 12 with a short-lived instance of its +/// own, which is dropped before eframe creates the real one. +#[cfg(target_os = "windows")] +pub fn configure(options: &mut NativeOptions, backend_override: Option) -> Backends { + let backends = match backend_override { + Some(backends) => { + log::info!("graphics: WGPU_BACKEND override, backends {backends:?}"); + backends + } + None => { + let adapters = probe_dx12(options); + let backends = choose_backends(None, &adapters); + log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len()); + backends + } + }; + configure_backends(options, backends); + backends +} + +/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor +/// will require. Empty when this build has no DirectX 12 backend. +#[cfg(target_os = "windows")] +fn probe_dx12(options: &NativeOptions) -> Vec { + use eframe::wgpu::{Instance, InstanceDescriptor}; + // `Instance::new` panics when the build has no backend for the platform. + if !Instance::enabled_backend_features().contains(Backends::DX12) { + return Vec::new(); + } + let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else { + return Vec::new(); + }; + let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() }); + pollster::block_on(instance.enumerate_adapters(Backends::DX12)) + .iter() + .map(|adapter| { + let info = adapter.get_info(); + let required = (create.device_descriptor)(adapter).required_limits; + let meets_limits = required.check_limits(&adapter.limits()); + log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver); + Dx12Adapter { device_type: info.device_type, meets_limits } + }) + .collect() } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + fn configured_backends(backends: Backends) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure_backends(&mut options, backends); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; create.instance_descriptor.backends } + const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter { + Dx12Adapter { device_type, meets_limits } + } + #[test] - fn windows_default_initializes_only_dx12() { - // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. + fn windows_default_never_initializes_vulkan() { + // eframe's own default (PRIMARY | GL) would initialize Vulkan and OpenGL as well. let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - assert_eq!(configured_backends(None), Backends::DX12); + let gpu = [adapter(DeviceType::IntegratedGpu, true)]; + assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12); + // #120: whatever the DirectX 12 probe finds, and whatever the retry picks, no Vulkan. + let probes: [&[Dx12Adapter]; 4] = [&[], &gpu, &[adapter(DeviceType::Cpu, true)], &[adapter(DeviceType::IntegratedGpu, false)]]; + for adapters in probes { + let first = choose_backends(None, adapters); + assert!(!first.contains(Backends::VULKAN), "{adapters:?}"); + if let Some(second) = fallback_backends(None, first) { + assert!(!second.contains(Backends::VULKAN), "{adapters:?}"); + } + } } #[test] fn explicit_backend_override_is_preserved() { + let warp_only = [adapter(DeviceType::Cpu, true)]; for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + // Whatever the probe would have found, the override wins (the probe doesn't run then), + // and a failed start isn't retried with something the user didn't ask for. + assert_eq!(choose_backends(Some(backends), &[]), backends); + assert_eq!(choose_backends(Some(backends), &warp_only), backends); + assert_eq!(configured_backends(backends), backends); + assert_eq!(fallback_backends(Some(backends), backends), None); } } + + #[test] + fn gl_when_dx12_has_no_usable_gpu() { + // No DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags. + let warp_only = [adapter(DeviceType::Cpu, true)]; + assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12); + assert_eq!(choose_backends(None, &[]), Backends::GL); + // A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one. + let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)]; + assert_eq!(choose_backends(None, &weak_gpu), Backends::GL); + // Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU. + let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)]; + assert_eq!(choose_backends(None, &mixed), Backends::DX12); + } + + #[test] + fn failed_start_retries_with_the_other_backend() { + assert_eq!(fallback_backends(None, Backends::DX12), Some(Backends::GL)); + assert_eq!(fallback_backends(None, Backends::GL), Some(Backends::DX12)); + // Both were already tried: nothing left to fall back to. + assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None); + } } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a95..961a352 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -17,6 +17,9 @@ mod logging; #[cfg(target_os = "macos")] mod native_menu; +use std::cell::RefCell; +use std::rc::Rc; + use gridcraft_engine::Session; use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; @@ -51,6 +54,16 @@ impl eframe::App for App { } fn on_exit(&mut self) { save_prefs(&self.0); + // macOS (#117): end the process here, before eframe tears the window down. AccessKit's + // macOS adapter subclasses the window's content view and restores its class on teardown; + // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle + // `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends + // in a crash report. eframe calls `on_exit` on every way of quitting (Quit/⌘Q, the Dock, + // the close button, `app.quit`), and nothing after it is needed: the preferences are + // saved and the log file is unbuffered. eframe does the same when it doesn't + // run-and-return. `on_exit` only runs once the app exists, never on a failed start. + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -84,6 +97,8 @@ fn load_prefs(app: &mut SheetApp) { } // First run (or a ui.json without a usable language): follow the desktop's language. app.ui.language = gridcraft_ui_egui::i18n::Language::system(); + // ...and its light or dark appearance (#239), until a theme is chosen and saved in ui.json. + app.ui.system_theme = true; let Some(dir) = config_dir() else { return }; if let Ok(b) = std::fs::read(dir.join("ui.json")) && let Ok(ui) = serde_json::from_slice::(&b) @@ -116,14 +131,23 @@ fn save_prefs(app: &SheetApp) { fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. - let mut clipboard: Option = None; + let clipboard: Rc>> = Rc::default(); + let paste_clipboard = clipboard.clone(); Services { copy_html: Some(Box::new(move |html, text| { + let mut clipboard = clipboard.borrow_mut(); if clipboard.is_none() { - clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + *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()) })), + paste_html: Some(Box::new(move || { + let mut clipboard = paste_clipboard.try_borrow_mut().ok()?; + if clipboard.is_none() { + *clipboard = arboard::Clipboard::new().ok(); + } + clipboard.as_mut()?.get().html().ok() + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) @@ -165,6 +189,9 @@ fn services() -> Services { } fn icon() -> Option { + #[cfg(target_os = "macos")] + let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png"); let img = image::load_from_memory(bytes).ok()?.to_rgba8(); Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) @@ -260,37 +287,65 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when + // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). #[cfg(target_os = "windows")] - let options = { + let backend_override = eframe::wgpu::Backends::from_env(); + #[cfg(target_os = "windows")] + let (options, first_backends) = { let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options + let backends = graphics::configure(&mut options, backend_override); + (options, backends) }; + #[cfg(target_os = "windows")] + let retry_options = options.clone(); // Registered before the event loop starts, so it catches the Finder event that launched us as // well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; - eframe::run_native( - "GridCraft", - options, - Box::new(move |cc| { - SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); - if let Some(port) = control_port { - match control_server::start(port, cc.egui_ctx.clone()) { - Ok(rx) => app.control_rx = Some(rx), - Err(e) => log::error!("control channel on port {port}: {e}"), + // The app waits here until a start gets as far as creating it; a start that fails before + // (no graphics adapter, device or surface) leaves it here for the next attempt. + let pending = std::cell::Cell::new(Some(app)); + let run = |options: eframe::NativeOptions| { + eframe::run_native( + "GridCraft", + options, + Box::new(|cc| { + let Some(mut app) = pending.take() else { + return Err("the app was already created".into()); + }; + SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); + if let Some(port) = control_port { + match control_server::start(port, cc.egui_ctx.clone()) { + Ok(rx) => app.control_rx = Some(rx), + Err(e) => log::error!("control channel on port {port}: {e}"), + } } - } - Ok(Box::new(App( - app, - #[cfg(target_os = "macos")] - None, - #[cfg(target_os = "macos")] - apple_events.connect(&cc.egui_ctx), - ))) - }), - ) + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + None, + #[cfg(target_os = "macos")] + apple_events.connect(&cc.egui_ctx), + ))) + }), + ) + }; + let result = run(options); + // A graphics error before the app existed: start once more with the other backend (eframe + // reuses its event loop for the second run). Errors after that, or from winit, are returned. + #[cfg(target_os = "windows")] + if let Err(eframe::Error::Wgpu(e)) = &result + && let Some(app) = pending.take() + && let Some(backends) = graphics::fallback_backends(backend_override, first_backends) + { + log::error!("graphics: the renderer didn't start on {first_backends:?} ({e}); trying {backends:?}"); + pending.set(Some(app)); + let mut options = retry_options; + graphics::configure_backends(&mut options, backends); + return run(options); + } + result } diff --git a/contributors/people.toml b/contributors/people.toml index 78683ae..734bf81 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,20 @@ sync_github_name = false [people.edurbs] sync_github_name = true + +[people.vangel76] +real_name = "Valentin Klein" +sync_github_name = true + +[people.MacJedi42] +sync_github_name = true + +[people.alexanderalber] +real_name = "Alexander Alber" + +[people.webdevtodayjason] +display_name = "Jason Brashear" +sync_github_name = false + +[people.Losminek] +sync_github_name = true diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index eff659d..3d4a743 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -71,6 +71,11 @@ pub trait Host { fn used_range(&mut self, sheet: usize) -> Option { self.workbook().sheet(sheet).and_then(|s| s.used_range()) } + /// Calls `f` on each stored value of a block, row-major (formula cells evaluated first), + /// until `f` returns false. `false` when this host can't (callers use `range_values`). + fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { + false + } } pub struct Evaluator<'h> { @@ -554,7 +559,20 @@ impl<'h> Evaluator<'h> { if args.len() < 2 { return err(CellError::Value); } - let i = match self.arg_num(args, 0, 0.0) { + let index = self.value(&args[0]); + if let Value::Array(_) = index { + // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` + // is a two-column array. The library CHOOSE lifts over its arguments. + let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for a in &args[1..] { + let value = self.value(a); + evaluated.push(Arg { value, from_ref: false }); + } + let mut ctx = FnCtx { ev: self }; + return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); + } + let i = match index.to_number() { Ok(n) => n.trunc() as i64, Err(e) => return err(e), }; @@ -664,6 +682,7 @@ impl<'h> Evaluator<'h> { "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), "CELL" => self.cell_info(args), + "TRIMRANGE" => self.trimrange(args), "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), "TEXT" => { if args.len() != 2 { @@ -726,6 +745,9 @@ impl<'h> Evaluator<'h> { if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; @@ -762,6 +784,70 @@ impl<'h> Evaluator<'h> { gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + Value::Number(n) => acc.sum += n, + other => return Err(Ev::V(other)), + } + } + if kind != Agg::Sum { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { if args.len() > l.params.len() { return err(CellError::Value); @@ -1239,6 +1325,57 @@ pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option { Some(format!("{sheet}{out}")) } +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +struct Acc { + sum: f64, + count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + // ---------------------------------------------------------------- operators fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value { @@ -1455,6 +1592,7 @@ fn is_special(n: &str) -> bool { | "INDIRECT" | "INDEX" | "CELL" + | "TRIMRANGE" | "SUBTOTAL" | "AGGREGATE" | "TEXT" @@ -1492,6 +1630,7 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[ "INDIRECT", "INDEX", "CELL", + "TRIMRANGE", "SUBTOTAL", "AGGREGATE", "TEXT", diff --git a/crates/calc/src/lib.rs b/crates/calc/src/lib.rs index 6668b44..27c9e19 100644 --- a/crates/calc/src/lib.rs +++ b/crates/calc/src/lib.rs @@ -5,6 +5,7 @@ pub mod eval; pub mod recalc; +mod trimrange; pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function}; pub use recalc::{Calc, Key, evaluate, now_serial}; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a..74c406b 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -598,6 +598,30 @@ impl Host for PassHost<'_> { } out } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula cells first, in row-major order like `range_values`: reading one calculates it + // and the pass remembers the value, so the walk below gets the same value again without + // this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } fn used_range(&mut self, sheet: usize) -> Option { let wb = self.wb; *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) @@ -974,13 +998,13 @@ fn prof_now() -> f64 { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::time::{Duration, Instant}; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; - use super::{Calc, Intervals, Key}; + use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost}; /// xorshift64 for repeatable pseudo-random cases. fn rng(state: &mut u64) -> u64 { @@ -1181,4 +1205,41 @@ mod tests { calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps); assert_eq!(deps, vec![at("DA9"), at("DA9")]); } + + /// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the + /// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been + /// calculated yet, or `None` when it declines the block. + pub(crate) fn probe_visit_range(wb: &Workbook, pending: &[(&str, &str)], range: RangeRef) -> Option> { + let mut exprs = HashMap::default(); + for (at, formula) in pending { + let expr = gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).ok()?; + exprs.insert((0, CellRef::parse(at)?), std::sync::Arc::new(expr)); + } + + // A cell in `pending` is calculated the first time something reads it, which is the state + // of a real pass partway through; `visit_range` has to cope with the formulas it reads + // being calculated under it. + let mut rng = 1u64; + let mut host = PassHost { + wb, + pending: exprs.keys().copied().collect(), + exprs: &exprs, + results: HashMap::default(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng: &mut rng, + now: 0.0, + cycle: false, + cycles: vec![], + depth: 0, + }; + + let mut seen = Vec::new(); + host.visit_range(0, range, &mut |v| { + seen.push(v.clone()); + true + }) + .then_some(seen) + } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d1e8576..2205a1e 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -219,6 +219,25 @@ fn ifs_and_lazy() { assert_eq!(t.get("B4"), Value::from("five")); } +#[test] +fn choose_with_an_array_index() { + let mut t = T::new(); + for (i, (k, v)) in [(1, 10), (2, 20), (3, 30)].iter().enumerate() { + t.set(&format!("A{}", i + 1), &k.to_string()); + t.set(&format!("B{}", i + 1), &v.to_string()); + } + t.set("D1", "=CHOOSE({1,2},\"a\",\"b\")"); + assert_eq!(t.get("D1"), Value::from("a")); + assert_eq!(t.get("E1"), Value::from("b")); + // The usual "look left" idiom: a two-column array from two ranges. + t.set("D3", "=VLOOKUP(20,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE)"); + assert_eq!(t.num("D3"), 2.0); + t.set("D4", "=SUM(CHOOSE({1,2},A1:A3,B1:B3))"); + assert_eq!(t.num("D4"), 66.0); + t.set("D5", "=CHOOSE(2,A1:A3,B1:B3)"); + assert_eq!(t.num("D7"), 30.0); +} + #[test] fn references_functions() { let mut t = T::new(); @@ -467,3 +486,223 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { assert_eq!(t.num("C1"), 10.0); assert_eq!(t.num("C2"), 5.0); } + +#[test] +fn trimrange() { + let mut t = T::new(); + t.set("C3", "1"); + t.set("D5", "2"); + let cases = [ + ("J1", "=ROWS(TRIMRANGE(A1:F10))", 3.0), + ("J2", "=COLUMNS(TRIMRANGE(A1:F10))", 2.0), + ("J3", "=ROWS(TRIMRANGE(A1:F10,2))", 5.0), + ("J4", "=ROWS(TRIMRANGE(A1:F10,1,0))", 8.0), + ("J5", "=COLUMNS(TRIMRANGE(A1:F10,1,0))", 6.0), + ("J6", "=ROWS(TRIMRANGE(A1:F10,0,0))", 10.0), + ("J7", "=SUM(TRIMRANGE(C:C))", 1.0), + ("J8", "=ROWS(TRIMRANGE(C:C))", 1.0), + ("J9", "=ROWS(TRIMRANGE(C:C,2))", 3.0), + ("J10", "=MIN(ROW(TRIMRANGE(A1:F10)))", 3.0), + ("J11", "=COLUMNS(TRIMRANGE({1,2;3,4}))", 2.0), + ]; + for (at, f, _) in cases { + t.set(at, f); + } + for (at, f, want) in cases { + assert_eq!(t.num(at), want, "{f}"); + } + // The result spills as the trimmed block and follows edits. + t.set("L1", "=TRIMRANGE(A1:F10)"); + assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0)); + t.set("E6", "3"); + assert_eq!((t.num("J1"), t.num("J2")), (4.0, 3.0)); + // Errors: a bad trim code, nothing but blanks, several areas. + t.set("K1", "=TRIMRANGE(A1:F10,4)"); + assert_eq!(t.get("K1"), Value::Error(CellError::Value)); + t.set("K2", "=TRIMRANGE(Q1:Q9)"); + assert_eq!(t.get("K2"), Value::Error(CellError::Calc)); + t.set("K3", "=TRIMRANGE((A1:B2,C3:D4))"); + assert_eq!(t.get("K3"), Value::Error(CellError::Value)); +} + +/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it: +/// +/// ```text +/// A B C E +/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges) +/// 2 2 TRUE #DIV/0! (E2 only when `error` is set) +/// 3 text 3 +/// 4 -0.25 +/// ``` +fn aggregate_of(error: bool, formula: &str) -> Value { + let mut t = T::new(); + for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] { + t.set(at, v); + } + if error { + t.set("E2", "#DIV/0!"); + } + + // AB1 is outside every range used, so the formula can't read itself. + t.set("AB1", formula); + t.get("AB1") +} + +#[test] +fn sum_count_average_over_ranges() { + // Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole + // columns; blocks holding an error or nothing numeric. The expected values are what the + // array path (the ranges copied into an array) gave before the ranges were folded in place, + // so any difference is a change of behavior. + let n = Value::Number; + let e = Value::Error; + + // (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula) + #[rustfmt::skip] + let cases: &[(&str, bool, [Value; 3])] = &[ + ("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]), + ("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]), + ("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]), + ("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]), + ("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]), + ("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]), + ("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]), + ("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]), + ("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]), + ("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]), + ("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]), + ("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]), + ]; + + for (formula, error, want) in cases { + for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) { + // The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`. + let f = formula.replace("SUM", func); + + assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})"); + } + } +} + +#[test] +fn sum_count_average_have_no_size_limit() { + // One stored cell far down stretches the used range to 27 million cells (27 columns by a + // million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are + // stored, so the sheet itself is tiny. + for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] { + for range in ["A1:AA1000000", "A:AA"] { + let mut t = T::new(); + t.set("A1", "1.5"); + t.set("Z1000000", "1"); + + t.set("AB1", &format!("={func}({range})")); + + assert_eq!(t.get("AB1"), want, "{func}({range})"); + } + } +} + +#[test] +fn sum_count_average_over_spilled_values() { + // A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no + // stored cell of their own). The host must not fold such a block in place: it falls back + // to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10. + let mut t = T::new(); + t.set("A1", "=SEQUENCE(3)"); + t.set("A4", "10"); + + for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] { + t.set("C1", &format!("={f}(A1:A5)")); + + assert_eq!(t.num("C1"), want, "{f}"); + } +} + +#[test] +fn aggregate_arguments_are_read_in_order() { + // Volatile cells draw a random number each time they are calculated, so the order in which + // the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must + // draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave + // 7.9043 here.) The expected values are what the array path gave; they depend on the random + // generator and its seed, and must be updated if either changes. + let mut t = T::new(); + t.set("B1", "1"); + t.set("B2", "=RAND()*10"); + + t.set("A1", "=SUM(B1:B2,RAND())"); + + assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356)); +} + +#[test] +fn sum_count_average_follow_edits_inside_the_range() { + // A range read in place must still count as a precedent of the formula: if it didn't, an + // edit inside it would leave the formula showing a stale value. Each step below changes one + // cell inside the range and checks all three functions; the last one changes a cell outside. + for range in ["A1:A5", "A:A"] { + let mut t = T::new(); + t.set("A1", "1"); + t.set("A3", "3"); + t.set("C1", &format!("=SUM({range})")); + t.set("C2", &format!("=COUNT({range})")); + t.set("C3", &format!("=AVERAGE({range})")); + let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3")); + assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}"); + + t.set("A1", "5"); + assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit"); + + t.set("A2", "10"); + assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank"); + + t.set("A3", "text"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text"); + + t.set("A2", ""); + assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear"); + + t.set("A5", "=A1*2"); + assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range"); + + t.set("A1", "1"); + assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes"); + + // A7 is inside the whole column A:A but outside A1:A5. + t.set("A7", "100"); + let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) }; + assert_eq!(all(&t), outside, "{range}: a cell outside the range"); + } +} + +#[test] +fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() { + // The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the + // block D1:D5 is read. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)"))); + sh.cells.set(c("D4"), Cell::value(Value::Number(10.0))); + let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap(); + + // Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells + // would never see 2 and 3, so the host must decline and leave the block to `range_values`. + // (`Calc` recalculates a pass that produced new spills, so final results are right either + // way; only this direct call shows the host's contract.) + let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block); + + assert_eq!(walked, None, "walked a block holding a spill it can't see"); +} diff --git a/crates/calc/src/trimrange.rs b/crates/calc/src/trimrange.rs new file mode 100644 index 0000000..4810277 --- /dev/null +++ b/crates/calc/src/trimrange.rs @@ -0,0 +1,108 @@ +//! TRIMRANGE: a range or array without its blank outer rows and columns (Microsoft's +//! documented semantics: trim_rows / trim_cols 0 none, 1 leading, 2 trailing, 3 both). + +use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use gridcraft_formula::Expr; + +use crate::eval::{Area, Ev, Evaluator, MAX_CELLS}; + +/// (leading, trailing) for a trim_rows / trim_cols code. +fn mode(code: f64) -> Result<(bool, bool), CellError> { + match code.trunc() { + 0.0 => Ok((false, false)), + 1.0 => Ok((true, false)), + 2.0 => Ok((false, true)), + 3.0 => Ok((true, true)), + _ => Err(CellError::Value), + } +} + +/// Rows and columns (first, last) holding a non-blank value in a row-major grid. +fn bounds(values: &[Value], width: usize) -> Option<((usize, usize), (usize, usize))> { + let mut b: Option<((usize, usize), (usize, usize))> = None; + for (i, v) in values.iter().enumerate() { + if matches!(v, Value::Empty) { + continue; + } + let (r, c) = (i / width.max(1), i % width.max(1)); + b = Some(match b { + None => ((r, r), (c, c)), + Some(((r0, r1), (c0, c1))) => ((r0.min(r), r1.max(r)), (c0.min(c), c1.max(c))), + }); + } + b +} + +/// The kept span of `0..len` given the non-blank span and the trim flags. +fn keep(len: usize, nonblank: (usize, usize), (lead, trail): (bool, bool)) -> (usize, usize) { + (if lead { nonblank.0 } else { 0 }, if trail { nonblank.1 } else { len.saturating_sub(1) }) +} + +impl Evaluator<'_> { + pub(crate) fn trimrange(&mut self, args: &[Expr]) -> Ev { + match self.trimrange_inner(args) { + Ok(ev) => ev, + Err(e) => Ev::V(Value::Error(e)), + } + } + + fn trim_mode(&mut self, args: &[Expr], i: usize) -> Result<(bool, bool), CellError> { + match args.get(i) { + None | Some(Expr::Missing) => Ok((true, true)), + Some(e) => match self.value(e).scalar() { + Value::Error(e) => Err(e), + v => mode(v.to_number()?), + }, + } + } + + fn trimrange_inner(&mut self, args: &[Expr]) -> Result { + let first = match args { + [first] | [first, _] | [first, _, _] => first, + _ => return Err(CellError::Value), + }; + let rows = self.trim_mode(args, 1)?; + let cols = self.trim_mode(args, 2)?; + let none = rows == (false, false) && cols == (false, false); + match self.eval(first) { + Ev::R(areas) => { + let [a] = areas.as_slice() else { return Err(CellError::Value) }; + if none { + return Ok(Ev::R(vec![*a])); + } + // Cells past the used range are blank: only the used part is read. + let part = self.trim(a); + let (h, w) = (part.height() as usize, part.width() as usize); + if (h as u64) * (w as u64) > MAX_CELLS { + return Err(CellError::Num); + } + let values = self.host.range_values(a.sheet, part); + let ((r0, r1), (c0, c1)) = bounds(&values, w).ok_or(CellError::Calc)?; + let (h_all, w_all) = (a.range.height() as usize, a.range.width() as usize); + let (kr0, kr1) = keep(h_all, (r0, r1), rows); + let (kc0, kc1) = keep(w_all, (c0, c1), cols); + let at = |r: usize, c: usize| CellRef::new(a.range.start.row.saturating_add(r as u32), a.range.start.col.saturating_add(c as u32)); + Ok(Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(at(kr0, kc0), at(kr1, kc1)) }])) + } + Ev::V(Value::Array(arr)) => { + if none { + return Ok(Ev::V(Value::Array(arr))); + } + let ((r0, r1), (c0, c1)) = bounds(&arr.data, arr.cols).ok_or(CellError::Calc)?; + let (kr0, kr1) = keep(arr.rows, (r0, r1), rows); + let (kc0, kc1) = keep(arr.cols, (c0, c1), cols); + let mut data = Vec::with_capacity((kr1 + 1 - kr0) * (kc1 + 1 - kc0)); + for r in kr0..=kr1 { + for c in kc0..=kc1 { + data.push(arr.get(r, c).cloned().unwrap_or(Value::Empty)); + } + } + Array::new(kr1 + 1 - kr0, kc1 + 1 - kc0, data).map(|a| Ev::V(Value::from(a))).ok_or(CellError::Value) + } + Ev::V(Value::Error(e)) => Err(e), + Ev::V(Value::Empty) if !none => Err(CellError::Calc), + Ev::V(v) => Ok(Ev::V(v)), + Ev::L(_) => Err(CellError::Value), + } + } +} diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index f3d517e..3d808e1 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range Some((sh, r)) } -/// Evaluates a series formula to a flat list of values (row-major), capped. +/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`. +fn union_parts(f: &str) -> Option> { + let inner = f.strip_prefix('(')?.strip_suffix(')')?; + let mut parts = Vec::new(); + let (mut depth, mut quoted, mut start) = (0i32, false, 0usize); + for (i, c) in inner.char_indices() { + match c { + '\'' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => depth -= 1, + ',' if !quoted && depth == 0 => { + parts.push(inner.get(start..i)?); + start = i + 1; + } + _ => {} + } + if depth < 0 { + return None; + } + } + parts.push(inner.get(start..)?); + (parts.len() > 1).then_some(parts) +} + +/// Evaluates a series formula to a flat list of values (row-major), capped. A union of +/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part. fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec { + eval_list_in(wb, sheet, formula, 0) +} + +fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec { let f = formula.trim(); if f.is_empty() { return vec![]; } + if depth < 4 + && let Some(parts) = union_parts(f) + { + let mut out = Vec::new(); + for p in parts { + if out.len() >= MAX_POINTS { + break; + } + let rest = MAX_POINTS - out.len(); + out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest)); + } + return out; + } match evaluate(wb, sheet, CellRef::default(), f) { Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(), v => vec![v], diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 09c46f8..24c1fd2 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart { y_title: Some("Revenue".into()), source: None, by_rows: false, + pivot: None, } } @@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() { let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); assert_eq!(bins, 10); } + +#[test] +fn union_series_references() { + let mut wb = Workbook::new(); + { + let s = wb.sheet_mut(0).unwrap(); + for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() { + s.set_value(CellRef::new(r as u32, 0), Value::from(*l)); + s.set_value(CellRef::new(r as u32, 1), Value::Number(*v)); + } + } + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![Series { + name: Some("\"Sum of X\"".into()), + categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()), + values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(), + ..Series::default() + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]); + assert_eq!(d.series[0].name, "Sum of X"); + assert_eq!(d.categories, vec!["a", "b", "d"]); + // Deeply nested parentheses from a file are bounded, not a crash. + c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000)); + let _ = resolve(&wb, 0, &c); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 8a64910..2bdfe79 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -30,14 +30,14 @@ impl CellRef { } /// Moved by `(dr, dc)`, `None` when the result leaves the sheet. pub fn offset(&self, dr: i64, dc: i64) -> Option { - let r = self.row as i64 + dr; - let c = self.col as i64 + dc; + let r = (self.row as i64).checked_add(dr)?; + let c = (self.col as i64).checked_add(dc)?; if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None } } /// Moved by `(dr, dc)` and clamped to the sheet. pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef { - let r = (self.row as i64 + dr).clamp(0, MAX_ROWS as i64 - 1); - let c = (self.col as i64 + dc).clamp(0, MAX_COLS as i64 - 1); + let r = (self.row as i64).saturating_add(dr).clamp(0, MAX_ROWS as i64 - 1); + let c = (self.col as i64).saturating_add(dc).clamp(0, MAX_COLS as i64 - 1); CellRef::new(r as u32, c as u32) } /// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range. @@ -272,6 +272,21 @@ mod tests { assert_eq!(a.iter().count(), 9); } + #[test] + fn extreme_offsets() { + let cell = CellRef::new(1, 1); + for delta in [i64::MIN, i64::MAX] { + assert_eq!(cell.offset(delta, 0), None); + assert_eq!(cell.offset(0, delta), None); + assert_eq!(cell.offset(delta, delta), None); + } + assert_eq!(cell.offset_clamped(i64::MAX, i64::MAX), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MIN), CellRef::new(0, 0)); + assert_eq!(cell.offset_clamped(i64::MAX, i64::MIN), CellRef::new(MAX_ROWS - 1, 0)); + assert_eq!(cell.offset_clamped(i64::MIN, i64::MAX), CellRef::new(0, MAX_COLS - 1)); + assert_eq!(cell.offset(2, -1), Some(CellRef::new(3, 0))); + } + #[test] fn offsets() { assert_eq!(CellRef::new(0, 0).offset(-1, 0), None); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index 7169ae5..c68f649 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -263,7 +263,8 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { } fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { - if !(1..=12).contains(&m) || d < 1 { + // Dates before 1900 stay text (`DATE()` would add 1900 to such a year). + if !(1..=12).contains(&m) || d < 1 || y < 1900 { return None; } let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64; @@ -290,6 +291,8 @@ pub fn current_year() -> i64 { fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { if let Some((tf, secs, ampm)) = parse_time(t) { let f = match (secs, ampm) { + // 24 hours or more: elapsed hours, so 25:00 shows as 25:00:00, not 1:00. + _ if tf >= 1.0 => "[h]:mm:ss", (false, false) => "h:mm", (true, false) => "h:mm:ss", (false, true) => "h:mm AM/PM", @@ -352,11 +355,18 @@ mod tests { assert_eq!(p("7-Oct-2026").value, Value::Number(46302.0)); assert_eq!(p("Oct 7, 2026").value, Value::Number(46302.0)); assert_eq!(p("2/30/2026").value, Value::text("2/30/2026")); + assert_eq!(p("7/4/1776").value, Value::text("7/4/1776")); + assert_eq!(p("1776-07-04").value, Value::text("1776-07-04")); + assert_eq!(p("4-Jul-1776").value, Value::text("4-Jul-1776")); + assert_eq!(p("12/31/1899").value, Value::text("12/31/1899")); + assert_eq!(p("1/1/1900").value, Value::Number(1.0)); assert_eq!(p("12:00").value, Value::Number(0.5)); assert_eq!(p("6:00 PM").value, Value::Number(0.75)); assert_eq!(p("12:00 AM").value, Value::Number(0.0)); assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75)); assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0)); + assert_eq!(p("25:00").format, Some("[h]:mm:ss")); + assert_eq!(p("23:59").format, Some("h:mm")); } #[test] diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index a102863..21a32dd 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -90,10 +90,7 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { ] } -/// Number of icons in a set name like `3TrafficLights1`, `5Quarters`. -pub fn icon_count(set: &str) -> usize { - set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3) as usize -} +pub use gridcraft_model::icon_count; impl CfCache { pub fn new() -> Self { @@ -144,7 +141,8 @@ impl CfCache { CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, }; - (ok && !v.is_empty()).then_some(style.as_ref()) + // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. + (ok && !v.is_error()).then_some(style.as_ref()) } CfRule::Expression { formula, style } => { gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) @@ -154,7 +152,7 @@ impl CfCache { CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), CfRule::Duplicate { style } => { @@ -261,11 +259,18 @@ impl CfCache { CfRule::IconSet { set, thresholds, reverse, show_value } => { if let Some(n) = v.as_f64() { let k = icon_count(set); - let cuts: Vec = if thresholds.len() + 1 == k { - thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect() - } else { - (1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect() + // One threshold per icon, the first being the lower bound (as in the file); + // also accept the lower bound left out, else Excel's defaults. + let defaults; + let lower = match thresholds.len() { + n if n == k => thresholds.get(1..).unwrap_or_default(), + n if n + 1 == k => thresholds.as_slice(), + _ => { + defaults = default_icon_thresholds(set); + defaults.get(1..).unwrap_or_default() + } }; + let cuts: Vec = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect(); let mut idx = cuts.iter().filter(|cut| n >= **cut).count(); if *reverse { idx = k - 1 - idx.min(k - 1); @@ -281,10 +286,12 @@ impl CfCache { }; if let Some(st) = matched_style { any = true; - out.style = Some(match out.style.take() { - None => st.clone(), - Some(prev) => prev, - }); + // Every matching rule applies; where two set the same part, the rule with the + // higher priority (seen first) wins. + match &mut out.style { + None => out.style = Some(st.clone()), + Some(prev) => layer_under(prev, st), + } if cf.stop_if_true { break; } @@ -294,6 +301,39 @@ impl CfCache { } } +/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts +/// are unset). +fn layer_under(style: &mut Style, lower: &Style) { + if style.fill.pattern == PatternType::None { + style.fill = lower.fill; + } + if style.font.color == Color::Auto { + style.font.color = lower.font.color; + } + style.font.bold |= lower.font.bold; + style.font.italic |= lower.font.italic; + style.font.strike |= lower.font.strike; + if style.font.underline == Underline::None { + style.font.underline = lower.font.underline; + } + let b = &mut style.borders; + for (dst, src) in [ + (&mut b.left, lower.borders.left), + (&mut b.right, lower.borders.right), + (&mut b.top, lower.borders.top), + (&mut b.bottom, lower.borders.bottom), + (&mut b.diag_down, lower.borders.diag_down), + (&mut b.diag_up, lower.borders.diag_up), + ] { + if dst.is_none() { + *dst = src; + } + } + if style.num_fmt.as_str() == "General" { + style.num_fmt = lower.num_fmt.clone(); + } +} + /// Shifts a rule formula written for the top-left cell of the range to `c`. fn relative(f: &str, first: CellRef, c: CellRef) -> String { if first == c { @@ -306,3 +346,68 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { Err(_) => f.to_string(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn every_matching_rule_applies() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap(); + let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}}); + s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap(); + s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + assert!(look("A1").is_none()); + let only_fill = look("A2").unwrap(); + assert_eq!(only_fill.fill.pattern, PatternType::Solid); + assert!(only_fill.borders.top.is_none()); + let both = look("A3").unwrap(); + assert_eq!(both.fill, only_fill.fill); + assert!(!both.borders.top.is_none()); + } + + #[test] + fn cell_value_rules_on_blanks_and_errors() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap(); + let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r}); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap(); + s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + let fill = |st: Option + +
Rp10,000.00Name
2.50 & upx
"#; + let rows = table_cells(excel); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); + assert_eq!(rows[1][0].text, "2.50 & up"); + assert_eq!(rows[1][2].text, "x"); + + let calc = r#" +
Rp10,000.00
"#; + assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + + let quoted_gt = r#"
1500
"#; + assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + } + + #[test] + fn hostile_markup_does_not_panic() { + for html in [ + "", + "<", + "", + "
a", + "�&", + "", + " + + +
1