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

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

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

GridCraft downloads

Flatpak repository

Release assets

- HTML - touch "$site/.nojekyll" - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: ${{ runner.temp }}/site - include-hidden-files: true - - deploy: - name: Deploy Pages - needs: build - runs-on: ubuntu-24.04 - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0a82a910f..6cdde6e47 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,95 +1,27 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. -# Test builds run only from the `release` branch (a manual run from any other branch is skipped). -# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run -# from a branch builds the tarball too; they need no secrets. -# -# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the -# release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the -# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the -# draft GitHub Release. name: FreeBSD on: + push: + branches: [release] + pull_request: + paths: + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh workflow_dispatch: - inputs: - package: - description: "Build the release tarball instead of running the tests" - type: boolean - default: false - workflow_call: - inputs: - version: - description: "Version for the artifact name (empty: Cargo.toml)" - type: string - default: "" - version_override: - description: "Also write the version into Cargo.toml before building (test runs)" - type: boolean - default: false - package: - type: boolean - default: false concurrency: - # Called from release.yml, github.workflow is the caller's name; keep the groups apart. - group: freebsd-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.workflow == 'FreeBSD' }} + group: freebsd-${{ github.ref }} + cancel-in-progress: true permissions: contents: read jobs: freebsd: - name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) - if: github.ref == 'refs/heads/release' || inputs.package - runs-on: ubuntu-latest - timeout-minutes: 300 - env: - GRIDCRAFT_VERSION: ${{ inputs.version }} - GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }} - GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - 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: Build in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA" - # Only the packaging run brings files back (dist/release). The target dir lives outside - # the synced checkout, because copying it out of the VM fails on large builds. - copyback: ${{ inputs.package }} - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0 - rustc --version - if [ -n "$GRIDCRAFT_PACKAGE" ]; then - if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then - cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - fi - CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh - else - cargo build --workspace --all-targets --locked - CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked - fi - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: inputs.package - with: - name: release-freebsd - path: dist/release/* - if-no-files-found: error + # 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/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01cb9cd66 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # 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/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87d895415..83b5efab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 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 }} diff --git a/Cargo.lock b/Cargo.lock index 2dec069dd..4edd4cb92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1480,9 +1496,10 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.5.0" +version = "0.9.0" dependencies = [ "arboard", + "craft-print", "eframe", "egui", "fmv-macos-events", @@ -1491,6 +1508,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "pollster 1.0.1", "rfd", "serde_json", @@ -1499,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1511,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1522,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1534,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.5.0" +version = "0.9.0" dependencies = [ "js-sys", "serde", @@ -1544,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1565,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-core", "serde", @@ -1574,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-core", "regex", @@ -1582,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-engine", "log", @@ -1591,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1603,19 +1622,20 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.5.0" +version = "0.9.0" [[package]] name = "gridcraft-ui-egui" -version = "0.5.0" +version = "0.9.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "epaint_default_fonts", @@ -1635,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.5.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -1652,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.5.0" +version = "0.9.0" dependencies = [ "aes", "gridcraft-core", @@ -4506,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.9.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 11e4edf89..d1106f39c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index 678495f1d..ef9b906fa 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@

GridCraft

- Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust. + Spreadsheets and data; an open-source, high-performance modern spreadsheet application, built in pure Rust.

- A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS, + A fast, open-source, clean-room take on modern spreadsheet workflows. It runs natively on macOS, Windows, Linux and BSD, and in the browser via WebAssembly.
By the ArtCraft team.

@@ -24,6 +24,9 @@ Runs on macOS, Windows, Linux, BSD and the web License: MIT OR Apache-2.0 Agent-drivable over MCP +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -67,8 +70,8 @@ A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library

Formulas. 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.

-The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons

Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.

-A PivotTable summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas

PivotTables. Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.

+The sales dashboard in dark mode with the Insert ribbon tab open, showing Pivot Table, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons

Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.

+A pivot table summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the Pivot Table Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas

Pivot tables. Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.

The sales dashboard with the column chart selected: resize handles on the chart, its source data outlined in the table, the Chart Design tab open and the Format Chart Area pane on the right

Charts. Seventeen chart types, Chart Design tab and a Format pane; selecting a chart outlines its source data.

@@ -78,19 +81,19 @@ ## Why GridCraft -- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill - handle, point-mode formula entry with coloured references, Format Cells, Paste Special, - conditional formatting, tables, charts and more. If you know Excel, you already know GridCraft. +- **Familiar.** Ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill handle, + point-mode formula entry with coloured references, Format Cells, Paste Special, + conditional formatting, tables, charts and more. Everything is right where you expect it. - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, - PivotTables and print settings round-trip. CSV and TSV too. - Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). + pivot tables and print settings round-trip. CSV and TSV too. + Legacy 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, - structured table references, Excel's full number-format language, sort and AutoFilter, + structured table references, a comprehensive number-format language, sort and AutoFilter, Flash Fill, data validation, outlining and subtotals. - **Fast.** Copy-on-write workbooks (undo snapshots are nearly free), incremental recalculation, and a virtualised grid that only lays out what's on screen. @@ -187,7 +190,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the | L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) | | L3 | `chart` (toolkit-free chart layout and rendering) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | -| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | +| L6 | `ui-egui` (spreadsheet UI, control channel) · `mcp` (MCP server) | | L7 | `apps/gridcraft`, `apps/gridcraft-cli`, `apps/gridcraft-web` | - **Contributor and agent rules** (clean-room, asset policy, never-crash, quality gates): [`AGENTS.md`](AGENTS.md) @@ -197,9 +200,9 @@ egui frontend is a separate crate, so the UI can be swapped without touching the ## Roadmap 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 513 of Excel's 523 worksheet functions -([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we +compatibility are still rough. It covers 89% of standard ribbon commands in our catalog +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 standard worksheet functions +([`docs/function-parity.md`](docs/function-parity.md)); this is a parity inventory, not a count of working implementations. Weighted by what spreadsheet 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, numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md). @@ -236,7 +239,7 @@ bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The 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 +graphics card with a driver for it, it only offers the Windows-provided 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 @@ -334,8 +337,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and GridCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. GridCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with. -

diff --git a/ROADMAP.md b/ROADMAP.md index fbe48d35d..070552d07 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-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **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. @@ -132,6 +132,22 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | +| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | +| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | +| 2026-10-12 | Security: cell hyperlinks open only http, https and mailto, after a confirmation; other schemes (file:, smb:, UNC, custom handlers) are refused (#777, part 1) | +| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) | +| 2026-10-12 | XLSX chart sheets are kept on open and save: the sheet, its drawing and chart (with style and colours) are written back unchanged in their place among the worksheets, and left out when the sheet they plot is deleted or renamed or a worksheet took their name (#727) | +| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) | +| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) | +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | +| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) | +| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | +| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) | +| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) | +| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | +| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | +| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -177,6 +193,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | +| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | +| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | +| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) | +| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | +| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 9fc81e30a..2b8529876 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -8,8 +8,9 @@ //! them are skipped. //! //! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is -//! validated (content types cover every part, every internal relationship target exists) and read -//! back, and the two workbooks are compared feature by feature. +//! validated (content types cover every part, every internal relationship target exists, no kind of +//! relationship the source had is missing) and read back, and the two workbooks are compared feature +//! by feature. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::{Cursor, Read as _}; @@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000; const MAX_XML_PART: u64 = 64 * 1024 * 1024; /// Most zip entries looked at while validating a written package. const MAX_ENTRIES: usize = 200_000; +/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is +/// a cache Excel rebuilds, and GridCraft never writes it. +const REBUILT_KINDS: [&str; 1] = ["calcchain"]; #[derive(Clone, Copy, Debug)] pub struct Options { @@ -97,6 +101,9 @@ pub struct CalcReport { /// aren't results, so every formula counts as no oracle. pub stale_cache: bool, pub volatile: usize, + /// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no + /// `t="b"`, as Business Central's Excel exports do); counted in `matched` too. + pub bool_as_number: usize, /// The first `max_report` mismatches, primary ones first. pub mismatches: Vec, pub functions: BTreeMap, @@ -286,7 +293,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { calc.recalc_all(&mut fresh); let trust_cache = !full_calc_on_load(bytes); report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts); - report.round_trip = round_trip(&fresh, opts); + report.round_trip = round_trip(&fresh, bytes, opts); report } @@ -347,6 +354,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { format!("{}!{}", quote_sheet(name), c.a1()) } +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + /// Whether GridCraft's `actual` agrees with the producer's `expected`. pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { match (expected, actual) { @@ -458,7 +472,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo } None => vec!["(unparsed)".to_string()], }; - let ok = values_match(&cell.value, &actual, opts.tolerance); + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } for n in &funcs { let st = r.functions.entry(n.clone()).or_default(); st.checked += 1; @@ -514,8 +532,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo // ---------------------------------------------------------------- round trip -/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares. -pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { +/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against +/// `source` (the file `wb` was read from), reads it back and compares. +pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { Ok(b) => b, Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, @@ -525,6 +544,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip { Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), Err(e) => package.push(|| e), } + if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { + for (key, kind) in &before { + if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { + package.push(|| format!("lost relationship {kind}")); + } + } + } let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { Ok((b, _)) => b, Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, @@ -559,6 +585,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet Vec { + let mut cfs: Vec<_> = s.cond_formats.iter().collect(); + cfs.sort_by_key(|c| c.priority); + cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() +} + fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { if a != b { d.push(|| format!("{sheet}: {a} → {b}")); @@ -634,7 +668,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec>(); let (ca, cb) = (chart_list(sa), chart_list(sb)); @@ -888,6 +927,77 @@ pub fn validate_package(bytes: &[u8]) -> Result, String> { Ok(issues) } +/// Every relationship kind (last segment of the type, the same in the transitional and strict +/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry +/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook +/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. +fn relationship_kinds(bytes: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let names = zip_names(&mut zip); + let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); + let mut kinds = BTreeMap::new(); + for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { + let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; + let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { + let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; + let target = attr(attrs, "Target").unwrap_or(""); + if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { + continue; + } + let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + if !external + && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) + && !holds_something(&mut zip, part, kind) + { + continue; + } + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + } + } + Ok(kinds) +} + +/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the +/// empty string for the package's `_rels/.rels`. +fn rels_source_dir(key: &str) -> Option { + let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; + Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string()) +} + +/// Whether a part has content: an element under its root, or for comments (whose authors list +/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. +fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool { + use quick_xml::events::Event; + let Ok(f) = zip.by_name(name) else { return true }; + let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART))); + let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() { + "comments" => Some(b"comment"), + "vmldrawing" => Some(b"shape"), + _ => None, + }; + let mut buf = Vec::new(); + let mut in_root = false; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let found = match wanted { + Some(w) => local_name(e.name().as_ref()) == w, + None => in_root, + }; + if found { + return true; + } + in_root = true; + } + Ok(Event::Eof) => return false, + Ok(_) => {} + Err(_) => return true, + } + buf.clear(); + } +} + // ---------------------------------------------------------------- output #[derive(Default)] @@ -901,6 +1011,7 @@ struct Totals { mismatched: usize, no_oracle: usize, volatile: usize, + bool_as_number: usize, round_trip_failed: usize, round_trip_differing: usize, categories: BTreeMap<&'static str, usize>, @@ -922,6 +1033,7 @@ fn totals(reports: &[FileReport]) -> Totals { t.mismatched += r.calc.mismatched; t.no_oracle += r.calc.no_oracle; t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; if r.round_trip.error.is_some() { t.round_trip_failed += 1; } @@ -982,7 +1094,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "warnings": r.warnings, "calc": { "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions), + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), "signatures": c.signatures, }, "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, @@ -996,7 +1109,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { "files": files, "totals": { "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, }, "functions": functions_json(&t.functions), @@ -1035,6 +1148,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { if c.stale_cache { outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } for m in &c.mismatches { let tag = if m.primary { "mismatch" } else { " (downstream)" }; outln!( @@ -1131,6 +1247,9 @@ fn print_human(reports: &[FileReport], opts: &Options) { t.no_oracle, t.volatile ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); outln!( "round trip: {} of {} files differ{}{}", @@ -1235,6 +1354,31 @@ mod tests { assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); } + /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: + /// the cache holds the right value under a number type, so it matches and is counted apart. + #[test] + fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); + } + #[test] fn missing_cached_values_and_volatile_formulas_are_skipped() { let wb = @@ -1277,7 +1421,8 @@ mod tests { fn round_trip_of_a_simple_book_is_clean() { let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let rt = round_trip(&wb, &Options::default()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); assert_eq!(rt.error, None); assert!(rt.is_clean(), "{rt:?}"); assert!(rt.categories.iter().any(|c| c.name == "package")); @@ -1303,6 +1448,27 @@ mod tests { assert_eq!(get("tables").diffs, 0); } + #[test] + fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = + |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); + } + fn zip_of(parts: &[(&str, &str)]) -> Vec { let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, body) in parts { @@ -1342,6 +1508,98 @@ mod tests { assert_eq!(validate_package(&own).unwrap(), Vec::::new()); } + const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; + const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; + const MADE_UP: &str = r#""#; + /// Not empty: an empty part isn't a loss. + const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + + /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. + fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() + } + + /// The `package` examples of checking `source`. + fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() + } + + fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() + } + + #[test] + fn package_reports_relationship_kinds_the_output_lost() { + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let calc_chain = r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); + } + + // In the three tests below the made-up kind stays lost, so the comparison is known to run. + + #[test] + fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + + #[test] + fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = + r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + } + #[test] fn unopenable_files_are_reported() { let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); @@ -1362,7 +1620,19 @@ mod tests { assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); assert_eq!( keys(&f["calc"]), - set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"]) + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) ); assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); @@ -1401,6 +1671,7 @@ mod tests { "mismatched", "noOracle", "volatile", + "boolAsNumber", "roundTripFailed", "roundTripDiffering", "categoriesDiffering" diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index d72bbd9eb..152ddbcbe 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -277,7 +277,7 @@ fn convert(args: &[String]) -> Result<(), String> { let mut s = open(input)?; activate_sheet(&mut s, a.opt("sheet"))?; let d = s.doc().map_err(|e| e.to_string())?; - let bytes = gridcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?; + let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?; gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; eprintln!("wrote {out} ({} bytes)", bytes.len()); Ok(()) diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 0f69e38e7..eac6c1354 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs new file mode 100644 index 000000000..2286023e5 --- /dev/null +++ b/apps/gridcraft-web/src/host.rs @@ -0,0 +1,166 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! GridCraft in an `