From beb353943e3bc88b0d5ffe374c9c3a0d7952e336 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:00:15 -0400 Subject: [PATCH] M1: Excel-style egui UI, desktop app, chart renderer, MCP server, CLI, release packaging - ui-egui: title bar with QAT, full ribbon (Home/Insert/Draw/Page Layout/Formulas/Data/ Review/View/Automate + Table/Chart Design), formula bar with Name Box, virtualised grid (frozen panes, overflow, borders, CF data bars/scales/icons, tables, sparklines, filter buttons, charts/pictures/shapes), in-cell editor with point mode, reference colouring, autocomplete and argument hints, sheet tabs, status bar, dialogs, control channel - apps/sheetcraft: desktop app (control server, file dialogs, prefs, drag & drop) - chart: toolkit-free chart layout/rendering for 25 chart kinds + sparklines + rasteriser - mcp + sheetcraft-cli: MCP server (headless or bridged) and CLI (info/convert/eval/cat/run/mcp) - xtask (ci/layers/assets/wasm/version/parity), release workflows, packaging, placeholder icon - automatic row heights Co-Authored-By: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 101 + .github/workflows/packaging-lint.yml | 57 + .github/workflows/release.yml | 339 ++ .github/workflows/windows-arm64.yml | 95 + ATTRIBUTION.md | 16 + Cargo.lock | 4106 ++++++++++++++++- Cargo.toml | 4 +- apps/sheetcraft-cli/Cargo.toml | 20 + apps/sheetcraft-cli/src/main.rs | 573 +++ apps/sheetcraft-cli/tests/cli.rs | 152 + apps/sheetcraft/Cargo.toml | 27 + apps/sheetcraft/build.rs | 30 + apps/sheetcraft/src/control_server.rs | 67 + apps/sheetcraft/src/main.rs | 193 + assets/app-icon/LICENSE.txt | 13 + assets/app-icon/README.md | 52 + .../apps/ai.storyteller.sheetcraft.png | Bin 0 -> 4785 bytes .../16x16/apps/ai.storyteller.sheetcraft.png | Bin 0 -> 704 bytes .../24x24/apps/ai.storyteller.sheetcraft.png | Bin 0 -> 1264 bytes .../apps/ai.storyteller.sheetcraft.png | Bin 0 -> 9021 bytes .../32x32/apps/ai.storyteller.sheetcraft.png | Bin 0 -> 1828 bytes .../48x48/apps/ai.storyteller.sheetcraft.png | Bin 0 -> 2753 bytes .../apps/ai.storyteller.sheetcraft.png | Bin 0 -> 20444 bytes .../64x64/apps/ai.storyteller.sheetcraft.png | Bin 0 -> 3050 bytes .../apps/ai.storyteller.sheetcraft.svg | 29 + assets/app-icon/sheetcraft-1024.png | Bin 0 -> 44149 bytes assets/app-icon/sheetcraft-macos-512.png | Bin 0 -> 16994 bytes assets/app-icon/sheetcraft.icns | Bin 0 -> 145377 bytes assets/app-icon/sheetcraft.ico | Bin 0 -> 26895 bytes assets/app-icon/sheetcraft.svg | 29 + crates/calc/src/eval.rs | 64 +- crates/calc/src/tests.rs | 6 +- crates/chart/Cargo.toml | 16 + crates/chart/src/axis.rs | 164 + crates/chart/src/lib.rs | 158 + crates/chart/src/raster.rs | 263 ++ crates/chart/src/render.rs | 1470 ++++++ crates/chart/src/resolve.rs | 173 + crates/chart/src/sparkline.rs | 82 + crates/chart/src/tests.rs | 569 +++ crates/core/src/date.rs | 3 +- crates/core/src/parse.rs | 4 +- crates/engine/src/cf.rs | 53 +- crates/engine/src/cmd/data.rs | 168 +- crates/engine/src/cmd/edit.rs | 166 +- crates/engine/src/cmd/file.rs | 17 +- crates/engine/src/cmd/format.rs | 306 +- crates/engine/src/cmd/formulas.rs | 94 +- crates/engine/src/cmd/insert.rs | 274 +- crates/engine/src/cmd/inspect.rs | 7 +- crates/engine/src/cmd/mod.rs | 56 +- crates/engine/src/cmd/review.rs | 10 +- crates/engine/src/cmd/sheet.rs | 51 +- crates/engine/src/cmd/view.rs | 97 +- crates/engine/src/fill.rs | 65 +- crates/engine/src/io.rs | 13 +- crates/engine/src/lib.rs | 3 +- crates/engine/src/sample.rs | 34 +- crates/engine/src/selection.rs | 23 +- crates/engine/src/tables.rs | 7 +- crates/formula/src/adjust.rs | 16 +- crates/formula/src/tests.rs | 3 +- crates/mcp/Cargo.toml | 14 + crates/mcp/src/backend.rs | 113 + crates/mcp/src/headless.rs | 94 + crates/mcp/src/lib.rs | 36 + crates/mcp/src/server.rs | 197 + crates/mcp/src/tests.rs | 383 ++ crates/mcp/src/tools.rs | 616 +++ crates/model/src/features.rs | 114 +- crates/model/src/lib.rs | 5 +- crates/model/src/store.rs | 8 +- crates/model/src/style.rs | 6 +- crates/ui-egui/Cargo.toml | 21 + crates/ui-egui/examples/snapshot.rs | 90 + crates/ui-egui/src/chartview.rs | 325 ++ crates/ui-egui/src/control.rs | 430 ++ crates/ui-egui/src/dialogs.rs | 1406 ++++++ crates/ui-egui/src/editor.rs | 274 ++ crates/ui-egui/src/formula_bar.rs | 406 ++ crates/ui-egui/src/grid.rs | 1706 +++++++ crates/ui-egui/src/icons.rs | 609 +++ crates/ui-egui/src/lib.rs | 468 ++ crates/ui-egui/src/ribbon.rs | 1721 +++++++ crates/ui-egui/src/tabs.rs | 280 ++ crates/ui-egui/src/theme.rs | 353 ++ crates/ui-egui/src/widgets.rs | 200 + crates/xlsx/src/sheet_read.rs | 10 +- crates/xlsx/src/sheet_write.rs | 2 +- docs/cli.md | 46 + docs/control-protocol.md | 43 + docs/mcp.md | 87 + docs/release-playbook.md | 9 + docs/releasing.md | 250 + packaging/.shellcheckrc | 3 + packaging/env.sh | 69 + packaging/freebsd/package.sh | 61 + packaging/icons.sh | 58 + .../linux/ai.storyteller.sheetcraft.desktop | 14 + .../ai.storyteller.sheetcraft.metainfo.xml.in | 66 + .../linux/ai.storyteller.sheetcraft.mime.xml | 26 + .../flatpak/ai.storyteller.sheetcraft.yml | 59 + packaging/linux/nfpm.yaml | 70 + packaging/linux/package.sh | 120 + packaging/linux/postinst.sh | 13 + packaging/macos/Info.plist.in | 140 + packaging/macos/entitlements.plist | 9 + packaging/macos/import-cert.sh | 50 + packaging/macos/package.sh | 164 + packaging/web/.htaccess | 16 + packaging/web/README.md | 84 + packaging/web/_headers | 13 + packaging/web/package.sh | 42 + packaging/windows/app-build.rs.in | 43 + packaging/windows/package.ps1 | 131 + packaging/windows/sheetcraft.wxs | 88 + packaging/windows/sign.ps1 | 95 + xtask/Cargo.toml | 20 + xtask/src/assets.rs | 86 + xtask/src/ico.rs | 104 + xtask/src/layers.rs | 327 ++ xtask/src/main.rs | 216 + xtask/src/parity.rs | 151 + xtask/src/stats.rs | 118 + xtask/src/version.rs | 152 + 125 files changed, 22971 insertions(+), 297 deletions(-) create mode 100644 .github/workflows/freebsd.yml create mode 100644 .github/workflows/packaging-lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/windows-arm64.yml create mode 100644 apps/sheetcraft-cli/Cargo.toml create mode 100644 apps/sheetcraft-cli/src/main.rs create mode 100644 apps/sheetcraft-cli/tests/cli.rs create mode 100644 apps/sheetcraft/Cargo.toml create mode 100644 apps/sheetcraft/build.rs create mode 100644 apps/sheetcraft/src/control_server.rs create mode 100644 apps/sheetcraft/src/main.rs create mode 100644 assets/app-icon/LICENSE.txt create mode 100644 assets/app-icon/README.md create mode 100644 assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png create mode 100644 assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg create mode 100644 assets/app-icon/sheetcraft-1024.png create mode 100644 assets/app-icon/sheetcraft-macos-512.png create mode 100644 assets/app-icon/sheetcraft.icns create mode 100644 assets/app-icon/sheetcraft.ico create mode 100644 assets/app-icon/sheetcraft.svg create mode 100644 crates/chart/Cargo.toml create mode 100644 crates/chart/src/axis.rs create mode 100644 crates/chart/src/lib.rs create mode 100644 crates/chart/src/raster.rs create mode 100644 crates/chart/src/render.rs create mode 100644 crates/chart/src/resolve.rs create mode 100644 crates/chart/src/sparkline.rs create mode 100644 crates/chart/src/tests.rs create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/backend.rs create mode 100644 crates/mcp/src/headless.rs create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/mcp/src/tests.rs create mode 100644 crates/mcp/src/tools.rs create mode 100644 crates/ui-egui/Cargo.toml create mode 100644 crates/ui-egui/examples/snapshot.rs create mode 100644 crates/ui-egui/src/chartview.rs create mode 100644 crates/ui-egui/src/control.rs create mode 100644 crates/ui-egui/src/dialogs.rs create mode 100644 crates/ui-egui/src/editor.rs create mode 100644 crates/ui-egui/src/formula_bar.rs create mode 100644 crates/ui-egui/src/grid.rs create mode 100644 crates/ui-egui/src/icons.rs create mode 100644 crates/ui-egui/src/lib.rs create mode 100644 crates/ui-egui/src/ribbon.rs create mode 100644 crates/ui-egui/src/tabs.rs create mode 100644 crates/ui-egui/src/theme.rs create mode 100644 crates/ui-egui/src/widgets.rs create mode 100644 docs/cli.md create mode 100644 docs/control-protocol.md create mode 100644 docs/mcp.md create mode 100644 docs/release-playbook.md create mode 100644 docs/releasing.md create mode 100644 packaging/.shellcheckrc create mode 100644 packaging/env.sh create mode 100755 packaging/freebsd/package.sh create mode 100755 packaging/icons.sh create mode 100644 packaging/linux/ai.storyteller.sheetcraft.desktop create mode 100644 packaging/linux/ai.storyteller.sheetcraft.metainfo.xml.in create mode 100644 packaging/linux/ai.storyteller.sheetcraft.mime.xml create mode 100644 packaging/linux/flatpak/ai.storyteller.sheetcraft.yml create mode 100644 packaging/linux/nfpm.yaml create mode 100755 packaging/linux/package.sh create mode 100755 packaging/linux/postinst.sh create mode 100644 packaging/macos/Info.plist.in create mode 100644 packaging/macos/entitlements.plist create mode 100755 packaging/macos/import-cert.sh create mode 100755 packaging/macos/package.sh create mode 100644 packaging/web/.htaccess create mode 100644 packaging/web/README.md create mode 100644 packaging/web/_headers create mode 100755 packaging/web/package.sh create mode 100644 packaging/windows/app-build.rs.in create mode 100644 packaging/windows/package.ps1 create mode 100644 packaging/windows/sheetcraft.wxs create mode 100644 packaging/windows/sign.ps1 create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/assets.rs create mode 100644 xtask/src/ico.rs create mode 100644 xtask/src/layers.rs create mode 100644 xtask/src/main.rs create mode 100644 xtask/src/parity.rs create mode 100644 xtask/src/stats.rs create mode 100644 xtask/src/version.rs diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 000000000..9bdb2d7a2 --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -0,0 +1,101 @@ +# 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. +# +# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the +# release binaries and uploads `sheetcraft--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: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - packaging/freebsd/** + - .github/workflows/freebsd.yml + 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' }} + +permissions: + contents: read + +jobs: + freebsd: + name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) + runs-on: ubuntu-latest + timeout-minutes: 300 + env: + SHEETCRAFT_VERSION: ${{ inputs.version }} + SHEETCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }} + SHEETCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} + SHEETCRAFT_BUILD_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - name: Build in a FreeBSD VM + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + envs: "SHEETCRAFT_VERSION SHEETCRAFT_VERSION_OVERRIDE SHEETCRAFT_PACKAGE SHEETCRAFT_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/sheetcraft-target CARGO_PROFILE_DEV_DEBUG=0 + rustc --version + if [ -n "$SHEETCRAFT_PACKAGE" ]; then + if [ -n "$SHEETCRAFT_VERSION_OVERRIDE" ]; then + cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_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@v4 + if: inputs.package + with: + name: release-freebsd + path: dist/release/* + if-no-files-found: error diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml new file mode 100644 index 000000000..6fb6a2fd3 --- /dev/null +++ b/.github/workflows/packaging-lint.yml @@ -0,0 +1,57 @@ +# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +name: Packaging lint + +on: + push: + branches: [main, release] + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + pull_request: + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: packaging-lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + env: + ACTIONLINT_VERSION: 1.7.12 + steps: + - uses: actions/checkout@v4 + - 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/sheetcraft.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.sheetcraft.metainfo.xml" + appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.sheetcraft.metainfo.xml" + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.sheetcraft.yml')); assert m['id'] == 'ai.storyteller.sheetcraft' and m['command'] == 'sheetcraft'" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..84d21059f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,339 @@ +# Release pipeline: every push to `release` builds signed installers for every platform and +# creates or updates the draft GitHub Release "SheetCraft v". See docs/releasing.md. +# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step. +# +# All jobs 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. +name: Release + +on: + push: + branches: [release] + workflow_dispatch: + inputs: + version: + description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)" + type: string + default: "" + +permissions: + contents: read + +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 + SHEETCRAFT_BUILD_SHA: ${{ github.sha }} + INPUT_VERSION: ${{ inputs.version }} + +jobs: + version: + name: version + runs-on: ubuntu-latest + environment: release + outputs: + version: ${{ steps.v.outputs.version }} + date: ${{ steps.v.outputs.date }} + prerelease: ${{ steps.v.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + - 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 "### SheetCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" + + macos: + name: macOS universal + needs: version + runs-on: macos-15 + environment: release + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + key: release-macos-universal + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_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@v4 + 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: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + 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 "$SHEETCRAFT_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@v4 + 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 }} + environment: release + 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: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + NFPM_VERSION: 2.47.0 + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + 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 + 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@stable + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_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/sheetcraft$|metainfo|applications' + ldd target/release/sheetcraft + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.sheetcraft.yml')); assert m['id'] == 'ai.storyteller.sheetcraft'" + for appimage in dist/release/*.AppImage; do + chmod +x "$appimage" + APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + done + - uses: actions/upload-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + web: + name: Web (wasm) + needs: version + runs-on: ubuntu-latest + environment: release + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + TRUNK_VERSION: 0.21.14 + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + 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 "$SHEETCRAFT_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@v4 + with: + name: release-web + path: dist/release/* + 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 (sheetcraft--freebsd-x86_64.tar.gz). It needs no + # secrets, so the called workflow runs without the release environment. + 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, web, freebsd] + 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@v4 + 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="SheetCraft 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" diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 000000000..daad48c7e --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + 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@v4 + with: + name: sheetcraft-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@v4 + with: + name: sheetcraft-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 'SheetCraft' + foreach ($exe in 'sheetcraft.exe', 'sheetcraft-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 'sheetcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "sheetcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok sheetcraft-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 'sheetcraft.exe')) { throw 'uninstall left sheetcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3a539037b..73e36793f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,3 +17,19 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | +| `assets/app-icon/sheetcraft.svg` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later | +| `assets/app-icon/sheetcraft-1024.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from sheetcraft.svg | +| `assets/app-icon/sheetcraft-macos-512.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render | +| `assets/app-icon/sheetcraft.icns` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon | +| `assets/app-icon/sheetcraft.ico` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Windows exe icon | +| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, copy of sheetcraft.svg | +| `assets/app-icon/README.md` | SheetCraft contributors | original | MIT OR Apache-2.0 | Icon documentation | +| `assets/app-icon/LICENSE.txt` | (licence text) | original | — | Licence of the app icon files | diff --git a/Cargo.lock b/Cargo.lock index 0b5c7d67d..eb4a27c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,138 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +143,52 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.5.0" @@ -20,18 +198,504 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -41,6 +705,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -49,29 +750,717 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] [[package]] name = "indexmap" @@ -80,7 +1469,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", ] [[package]] @@ -89,6 +1506,197 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -101,6 +1709,734 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -110,6 +2446,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -119,6 +2473,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -128,6 +2491,100 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "regex" version = "1.13.1" @@ -157,6 +2614,125 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -184,7 +2760,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -200,6 +2776,41 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sheetcraft" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "image", + "log", + "rfd", + "serde_json", + "sheetcraft-engine", + "sheetcraft-ui-egui", + "winresource", +] + [[package]] name = "sheetcraft-calc" version = "0.1.0" @@ -212,13 +2823,34 @@ dependencies = [ "sheetcraft-numfmt", ] +[[package]] +name = "sheetcraft-chart" +version = "0.1.0" +dependencies = [ + "serde", + "sheetcraft-calc", + "sheetcraft-core", + "sheetcraft-model", + "sheetcraft-numfmt", +] + +[[package]] +name = "sheetcraft-cli" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "sheetcraft-engine", + "sheetcraft-mcp", +] + [[package]] name = "sheetcraft-core" version = "0.1.0" dependencies = [ "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -235,7 +2867,7 @@ dependencies = [ "sheetcraft-model", "sheetcraft-numfmt", "sheetcraft-xlsx", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -244,7 +2876,7 @@ version = "0.1.0" dependencies = [ "serde", "sheetcraft-core", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -255,6 +2887,15 @@ dependencies = [ "sheetcraft-core", ] +[[package]] +name = "sheetcraft-mcp" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "sheetcraft-engine", +] + [[package]] name = "sheetcraft-model" version = "0.1.0" @@ -263,7 +2904,7 @@ dependencies = [ "serde_json", "sheetcraft-core", "sheetcraft-formula", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -273,24 +2914,205 @@ dependencies = [ "sheetcraft-core", ] +[[package]] +name = "sheetcraft-ui-egui" +version = "0.1.0" +dependencies = [ + "egui", + "egui_kittest", + "image", + "log", + "serde", + "serde_json", + "sheetcraft-chart", + "sheetcraft-engine", +] + [[package]] name = "sheetcraft-xlsx" version = "0.1.0" dependencies = [ - "quick-xml", + "quick-xml 0.38.4", "sheetcraft-core", "sheetcraft-formula", "sheetcraft-model", - "thiserror", + "thiserror 2.0.21", "zip", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -302,13 +3124,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -319,15 +3183,1179 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", + "sheetcraft-engine", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zip" version = "3.0.0" @@ -365,3 +4393,59 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 7cd0d6507..fd5c98236 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/*"] -# default-members restored once apps exist +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/sheetcraft", "apps/sheetcraft-cli", "xtask"] [workspace.package] version = "0.1.0" diff --git a/apps/sheetcraft-cli/Cargo.toml b/apps/sheetcraft-cli/Cargo.toml new file mode 100644 index 000000000..571905af8 --- /dev/null +++ b/apps/sheetcraft-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sheetcraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft command line: inspect, convert, evaluate, script and serve MCP without a window" +publish = false + +[lints] +workspace = true + +[[bin]] +name = "sheetcraft-cli" +path = "src/main.rs" + +[dependencies] +sheetcraft-engine = { workspace = true } +sheetcraft-mcp = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/apps/sheetcraft-cli/src/main.rs b/apps/sheetcraft-cli/src/main.rs new file mode 100644 index 000000000..b933ef157 --- /dev/null +++ b/apps/sheetcraft-cli/src/main.rs @@ -0,0 +1,573 @@ +//! Headless SheetCraft. +//! +//! ```text +//! sheetcraft-cli info [--json] +//! sheetcraft-cli convert [--sheet NAME] +//! sheetcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] +//! sheetcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] +//! sheetcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] +//! sheetcraft-cli commands [--json] [--search X] +//! sheetcraft-cli functions [--json] [--search X] [--category C] +//! sheetcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] +//! sheetcraft-cli send [json] +//! sheetcraft-cli version | --version +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +/// `println!` that ends the program quietly when stdout is closed (`sheetcraft-cli commands | +/// head`) instead of panicking on a broken pipe. +macro_rules! outln { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} + +use serde_json::{Value, json}; +use sheetcraft_engine::Session; + +const USAGE: &str = "usage: + sheetcraft-cli info [--json] sheets, used ranges, tables, charts, names + sheetcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html by extension + sheetcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] + sheetcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] + sheetcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] + [--out FILE] [--print RANGE] [--quiet] + sheetcraft-cli commands [--json] [--search X] + sheetcraft-cli functions [--json] [--search X] [--category C] + sheetcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] + sheetcraft-cli send [json] + sheetcraft-cli version"; + +/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any +/// other write error is reported. +fn stdout_failed(e: std::io::Error) -> ! { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + eprintln!("sheetcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let rest = args.get(1..).unwrap_or_default(); + let r = match args.first().map(String::as_str) { + Some("--version" | "-V" | "version") => { + outln!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some("--help" | "-h" | "help") => { + outln!("{USAGE}"); + Ok(()) + } + Some("info") => info(rest), + Some("convert") => convert(rest), + Some("eval") => eval(rest), + Some("cat") => cat(rest), + Some("run") => run(rest), + Some("commands") => commands(rest), + Some("functions") => functions(rest), + Some("mcp") => mcp(rest), + Some("send") => send(rest), + Some(other) => Err(format!("unknown subcommand `{other}`\n{USAGE}")), + None => Err(USAGE.to_string()), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("sheetcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +// ---------------------------------------------------------------- argument parsing + +/// Parsed arguments: positionals, `--flag value` options and boolean `--switch`es. +struct Args { + positional: Vec, + opts: Vec<(String, String)>, + switches: Vec, +} + +impl Args { + /// `with_value` lists the options that take a value; `switches` the boolean ones. Anything + /// else starting with `--` is an error. + fn parse(args: &[String], with_value: &[&str], switches: &[&str]) -> Result { + let mut out = Args { positional: vec![], opts: vec![], switches: vec![] }; + let mut it = args.iter(); + while let Some(a) = it.next() { + if let Some(name) = a.strip_prefix("--").filter(|n| !n.is_empty()) { + let (name, inline) = match name.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (name, None), + }; + if with_value.contains(&name) { + let v = match inline { + Some(v) => v, + None => it.next().cloned().ok_or_else(|| format!("--{name} needs a value"))?, + }; + out.opts.push((name.to_string(), v)); + } else if switches.contains(&name) && inline.is_none() { + out.switches.push(name.to_string()); + } else { + return Err(format!("unknown option --{name}")); + } + } else { + out.positional.push(a.clone()); + } + } + Ok(out) + } + fn opt(&self, name: &str) -> Option<&str> { + self.opts.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) + } + fn has(&self, name: &str) -> bool { + self.switches.iter().any(|s| s == name) + } + fn pos(&self, i: usize, what: &str) -> Result<&str, String> { + self.positional.get(i).map(String::as_str).ok_or_else(|| format!("missing {what}\n{USAGE}")) + } + fn no_extra(&self, n: usize) -> Result<(), String> { + match self.positional.get(n) { + Some(x) => Err(format!("unexpected argument `{x}`")), + None => Ok(()), + } + } +} + +// ---------------------------------------------------------------- helpers + +fn exec(s: &mut Session, id: &str, params: Value) -> Result { + let r = s.execute(id, params).map_err(|e| e.to_string()); + s.take_ui_requests(); + r +} + +fn open(path: &str) -> Result { + let mut s = Session::new(); + exec(&mut s, "file.open", json!({"path": path}))?; + Ok(s) +} + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn activate_sheet(s: &mut Session, sheet: Option<&str>) -> Result<(), String> { + if let Some(name) = sheet { + let p = match name.parse::() { + // A number that isn't also a sheet name is an index. + Ok(i) if s.doc().is_ok_and(|d| d.wb.sheet_index(name).is_none()) => json!({"sheet": i}), + _ => json!({"sheet": name}), + }; + exec(s, "sheet.activate", p)?; + } + Ok(()) +} + +fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +/// A number as a person would type it: `6`, `0.5`, `1e+300`. +fn number_text(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") } +} + +/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text. +fn value_text(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + Value::Number(n) => n.as_f64().map(number_text).unwrap_or_else(|| n.to_string()), + Value::String(s) => s.clone(), + Value::Object(o) => o.get("error").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| v.to_string()), + Value::Array(rows) => rows + .iter() + .map(|r| match r { + Value::Array(cells) => cells.iter().map(value_text).collect::>().join("\t"), + other => value_text(other), + }) + .collect::>() + .join("\n"), + } +} + +fn csv_field(s: &str, delim: char) -> String { + if s.contains(delim) || s.contains('"') || s.contains('\n') || s.contains('\r') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +// ---------------------------------------------------------------- subcommands + +fn info(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["json"])?; + let path = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = open(path)?; + let v = exec(&mut s, "document.inspect", json!({}))?; + if a.has("json") { + outln!("{}", pretty(&v)); + return Ok(()); + } + outln!("{}", v["title"].as_str().unwrap_or(path)); + for (i, sh) in v["sheets"].as_array().into_iter().flatten().enumerate() { + let active = if v["activeSheet"].as_u64() == Some(i as u64) { "*" } else { " " }; + let vis = sh["visibility"].as_str().filter(|x| *x != "Visible").map(|x| format!(" ({x})")).unwrap_or_default(); + outln!( + "{active} {}{vis}: used {}, {} cells", + sh["name"].as_str().unwrap_or("?"), + sh["usedRange"].as_str().unwrap_or("(empty)"), + sh["cells"].as_u64().unwrap_or(0) + ); + for t in sh["tables"].as_array().into_iter().flatten() { + outln!(" table {} {} ({})", t["name"].as_str().unwrap_or("?"), t["range"].as_str().unwrap_or("?"), t["style"].as_str().unwrap_or("")); + } + for c in sh["charts"].as_array().into_iter().flatten() { + let title = c["title"].as_str().map(|t| format!(" \"{t}\"")).unwrap_or_default(); + outln!(" chart {}{title} at {}, {} series", c["kind"].as_str().unwrap_or("?"), c["at"].as_str().unwrap_or("?"), c["series"]); + } + if let Some(f) = sh["autofilter"].as_str() { + outln!(" filter {f}"); + } + } + let names = v["names"].as_array().cloned().unwrap_or_default(); + if !names.is_empty() { + outln!("names:"); + for n in names { + outln!(" {} {}", n["name"].as_str().unwrap_or("?"), n["refersTo"].as_str().unwrap_or("")); + } + } + Ok(()) +} + +fn convert(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["sheet"], &[])?; + let input = a.pos(0, "")?; + let out = a.pos(1, "")?; + a.no_extra(2)?; + if sheetcraft_engine::io::FileKind::from_path(out).is_none() { + return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)")); + } + let mut s = open(input)?; + activate_sheet(&mut s, a.opt("sheet"))?; + let d = s.doc().map_err(|e| e.to_string())?; + let bytes = sheetcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?; + sheetcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", bytes.len()); + Ok(()) +} + +fn eval(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["in", "sheet", "cell"], &["json"])?; + let formula = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = match a.opt("in") { + Some(p) => open(p)?, + None => blank(), + }; + activate_sheet(&mut s, a.opt("sheet"))?; + let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; + let mut p = json!({"formula": formula}); + if let Some(c) = a.opt("cell") { + p["cell"] = json!(c); + } + let v = exec(&mut s, "formulas.evaluate", p)?; + if a.has("json") { + outln!("{}", serde_json::to_string(&v).unwrap_or_default()); + } else { + outln!("{}", value_text(&v)); + } + Ok(()) +} + +/// Prints `range` of the active (or `sheet`) sheet as an aligned table or CSV. +fn print_range(s: &mut Session, range: Option<&str>, sheet: Option<&str>, formulas: bool, csv: bool) -> Result<(), String> { + let mut p = json!({}); + if let Some(r) = range { + p["range"] = json!(r); + } + if let Some(sh) = sheet { + p["sheet"] = json!(sh); + } + let raw = exec(s, "sheet.read", p.clone())?; + p["formatted"] = json!(true); + p["formulas"] = json!(formulas); + let shown = exec(s, "sheet.read", p)?; + let rows: Vec> = shown["values"] + .as_array() + .into_iter() + .flatten() + .map(|r| r.as_array().into_iter().flatten().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| value_text(c))).collect()) + .collect(); + if csv { + for r in &rows { + outln!("{}", r.iter().map(|c| csv_field(c, ',')).collect::>().join(",")); + } + return Ok(()); + } + let numeric = |ri: usize, ci: usize| raw["values"][ri][ci].is_number() && !formulas; + let ncols = rows.iter().map(Vec::len).max().unwrap_or(0); + let widths: Vec = (0..ncols).map(|c| rows.iter().filter_map(|r| r.get(c)).map(|t| t.chars().count()).max().unwrap_or(0).min(60)).collect(); + for (ri, r) in rows.iter().enumerate() { + let mut line = String::new(); + for (ci, cell) in r.iter().enumerate() { + let w = widths.get(ci).copied().unwrap_or(0); + let cell: String = cell.chars().map(|c| if c == '\n' || c == '\t' { ' ' } else { c }).collect(); + if ci > 0 { + line.push_str(" "); + } + if numeric(ri, ci) { + line.push_str(&format!("{cell:>w$}")); + } else { + line.push_str(&format!("{cell: Result<(), String> { + let a = Args::parse(args, &["range", "sheet"], &["formulas", "csv"])?; + let path = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = open(path)?; + print_range(&mut s, a.opt("range"), a.opt("sheet"), a.has("formulas"), a.has("csv")) +} + +/// One command from `id={json}`, `id {json}`, `id` or `{"command": id, "params": {..}}`. +fn parse_command(text: &str) -> Result, String> { + let t = text.trim(); + if t.is_empty() || t.starts_with('#') || t.starts_with("//") { + return Ok(None); + } + if t.starts_with('{') { + let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; + return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); + } + let split = t.find(['=', ' ', '\t']); + let (id, rest) = match split { + Some(i) => (t.get(..i).unwrap_or(t), t.get(i + 1..).unwrap_or("").trim()), + None => (t, ""), + }; + let params = if rest.is_empty() { json!({}) } else { serde_json::from_str(rest).map_err(|e| format!("{id}: bad JSON params: {e}"))? }; + Ok(Some((id.to_string(), params))) +} + +fn run(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["in", "sample", "cmd", "script", "out", "print", "sheet"], &["quiet", "csv", "formulas"])?; + a.no_extra(0)?; + let mut s = match (a.opt("in"), a.opt("sample")) { + (Some(_), Some(_)) => return Err("give --in or --sample, not both".into()), + (Some(p), None) => open(p)?, + (None, Some(name)) => { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"sample": name}))?; + s + } + (None, None) => blank(), + }; + let quiet = a.has("quiet"); + let mut step = 0usize; + for (name, value) in &a.opts { + let cmds: Vec<(String, Value, String)> = match name.as_str() { + "cmd" => parse_command(value)?.map(|(id, p)| vec![(id, p, "--cmd".to_string())]).unwrap_or_default(), + "script" => { + let text = std::fs::read_to_string(value).map_err(|e| format!("{value}: {e}"))?; + let mut v = Vec::new(); + for (i, line) in text.lines().enumerate() { + if let Some((id, p)) = parse_command(line).map_err(|e| format!("{value}:{}: {e}", i + 1))? { + v.push((id, p, format!("{value}:{}", i + 1))); + } + } + v + } + _ => continue, + }; + for (id, p, origin) in cmds { + step += 1; + let r = exec(&mut s, &id, p).map_err(|e| format!("step {step} ({origin}, {id}): {e}"))?; + if !quiet && !r.is_null() { + outln!("{}", serde_json::to_string(&json!({"command": id, "result": r})).unwrap_or_default()); + } + } + } + if let Some(out) = a.opt("out") { + let r = exec(&mut s, "file.save", json!({"path": out}))?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); + } + for (name, range) in &a.opts { + if name == "print" { + let range = if range.is_empty() || range == "used" { None } else { Some(range.as_str()) }; + print_range(&mut s, range, a.opt("sheet"), a.has("formulas"), a.has("csv"))?; + } + } + Ok(()) +} + +fn commands(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["search"], &["json"])?; + let q = a.opt("search").or(a.positional.first().map(String::as_str)).map(str::to_ascii_lowercase); + let s = Session::new(); + let list: Vec<_> = s + .commands() + .into_iter() + .filter(|c| { + q.as_ref().is_none_or(|q| { + c.id.to_ascii_lowercase().contains(q.as_str()) + || c.label.to_ascii_lowercase().contains(q.as_str()) + || c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str())) + }) + }) + .collect(); + if a.has("json") { + // Enablement depends on a live workbook; not meaningful here. + let v: Vec = + list.iter().map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params})).collect(); + outln!("{}", pretty(&Value::Array(v))); + return Ok(()); + } + let w = list.iter().map(|c| c.id.len()).max().unwrap_or(0); + for c in &list { + let sc = c.shortcut.map(|s| format!(" [{s}]")).unwrap_or_default(); + let menu = if c.menu.is_empty() { String::new() } else { format!(" ({})", c.menu.join(" > ")) }; + outln!("{: Result<(), String> { + let a = Args::parse(args, &["search", "category"], &["json"])?; + let mut p = json!({}); + if let Some(q) = a.opt("search").or(a.positional.first().map(String::as_str)) { + p["search"] = json!(q); + } + if let Some(c) = a.opt("category") { + p["category"] = json!(c); + } + let mut s = Session::new(); + let v = exec(&mut s, "formulas.functions", p)?; + if a.has("json") { + outln!("{}", pretty(&v)); + return Ok(()); + } + let list = v.as_array().cloned().unwrap_or_default(); + for f in &list { + let sig = f["signature"].as_str().or(f["name"].as_str()).unwrap_or("?"); + outln!("{sig}"); + let cat = f["category"].as_str().unwrap_or(""); + let desc = f["description"].as_str().unwrap_or(""); + outln!(" {cat}: {desc}"); + } + eprintln!("{} functions", list.len()); + Ok(()) +} + +/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app +/// started with `sheetcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. +fn mcp(args: &[String]) -> Result<(), String> { + use sheetcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; + let a = Args::parse(args, &["connect", "in", "sample"], &[])?; + a.no_extra(0)?; + let backend: Box = match a.opt("connect") { + Some(c) => { + let addr = control_addr(c); + Box::new( + Remote::connect(&addr) + .map_err(|e| format!("cannot connect to the SheetCraft app at {addr}: {e} (start it with `sheetcraft --control PORT`)"))?, + ) + } + None => { + let session = match (a.opt("in"), a.opt("sample")) { + (Some(p), _) => open(p)?, + (None, Some(name)) => { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"sample": name}))?; + s + } + (None, None) => blank(), + }; + Box::new(Headless::with_session(session)) + } + }; + eprintln!("sheetcraft-cli: MCP server on stdio ({})", backend.describe()); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) +} + +/// `send PORT METHOD [JSON]`: one control-channel request to a running app. +fn send(args: &[String]) -> Result<(), String> { + use sheetcraft_mcp::{Backend, Remote, control_addr}; + let port = args.first().ok_or_else(|| format!("missing \n{USAGE}"))?; + let method = args.get(1).ok_or_else(|| format!("missing \n{USAGE}"))?; + let params: Value = match args.get(2) { + Some(t) => serde_json::from_str(t).map_err(|e| format!("bad JSON params: {e}"))?, + None => json!({}), + }; + if let Some(x) = args.get(3) { + return Err(format!("unexpected argument `{x}`")); + } + let addr = control_addr(port); + let mut r = Remote::connect(&addr) + .map_err(|e| format!("cannot connect to the SheetCraft app at {addr}: {e} (start it with `sheetcraft --control {port}`)"))?; + let v = r.call(method, params)?; + outln!("{}", pretty(&v)); + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn parses_commands() { + assert_eq!(parse_command("home.bold={\"range\":\"A1\"}").unwrap(), Some(("home.bold".into(), json!({"range": "A1"})))); + assert_eq!(parse_command("edit.undo").unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command("cell.set {\"cell\":\"A1\",\"input\":\"=1\"}").unwrap(), + Some(("cell.set".into(), json!({"cell": "A1", "input": "=1"}))) + ); + assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); + assert_eq!(parse_command(" # comment").unwrap(), None); + assert!(parse_command("x={bad").is_err()); + } + + #[test] + fn value_texts() { + assert_eq!(value_text(&json!(6.0)), "6"); + assert_eq!(value_text(&json!(0.25)), "0.25"); + assert_eq!(value_text(&json!(true)), "TRUE"); + assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!"); + assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx"); + } + + #[test] + fn args_parsing() { + let v: Vec = ["a", "--range", "A1", "--csv", "--sheet=S"].iter().map(|s| s.to_string()).collect(); + let a = Args::parse(&v, &["range", "sheet"], &["csv"]).unwrap(); + assert_eq!(a.positional, ["a"]); + assert_eq!(a.opt("range"), Some("A1")); + assert_eq!(a.opt("sheet"), Some("S")); + assert!(a.has("csv")); + assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err()); + assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err()); + } +} diff --git a/apps/sheetcraft-cli/tests/cli.rs b/apps/sheetcraft-cli/tests/cli.rs new file mode 100644 index 000000000..f85903f21 --- /dev/null +++ b/apps/sheetcraft-cli/tests/cli.rs @@ -0,0 +1,152 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_sheetcraft-cli")) +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().expect("run sheetcraft-cli") +} + +fn ok(args: &[&str]) -> String { + let o = run(args); + assert!(o.status.success(), "sheetcraft-cli {args:?} failed: {}", String::from_utf8_lossy(&o.stderr)); + String::from_utf8(o.stdout).unwrap() +} + +fn tmpdir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("sheetcraft-cli-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn version_and_usage() { + assert_eq!(ok(&["--version"]).trim(), format!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION"))); + assert_eq!(ok(&["version"]).trim(), format!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION"))); + let o = run(&["frobnicate"]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("unknown subcommand")); + let o = run(&[]); + assert!(!o.status.success()); +} + +#[test] +fn eval_formulas() { + assert_eq!(ok(&["eval", "=SUM(1,2,3)"]).trim(), "6"); + assert_eq!(ok(&["eval", "1/4"]).trim(), "0.25"); + assert_eq!(ok(&["eval", "=UPPER(\"abc\")"]).trim(), "ABC"); + assert_eq!(ok(&["eval", "=1/0"]).trim(), "#DIV/0!"); + assert_eq!(ok(&["eval", "=2>1", "--json"]).trim(), "true"); + let o = run(&["eval", "=1", "--in", "/no/such/file.xlsx"]); + assert!(!o.status.success()); + assert!(!String::from_utf8_lossy(&o.stderr).is_empty()); +} + +#[test] +fn run_then_cat_and_info_on_sample() { + let dir = tmpdir("run"); + let book = dir.join("budget.xlsx"); + let book_s = book.to_str().unwrap(); + let script = dir.join("steps.jsonl"); + std::fs::write( + &script, + "# steps\n{\"command\":\"cell.set\",\"params\":{\"cell\":\"K2\",\"input\":\"=K1*2\"}}\nhome.bold {\"range\":\"K1:K2\"}\n", + ) + .unwrap(); + let out = ok(&[ + "run", + "--sample", + "budget", + "--cmd", + r#"cell.set={"cell":"K1","input":"21"}"#, + "--script", + script.to_str().unwrap(), + "--out", + book_s, + "--print", + "K1:K2", + "--quiet", + ]); + assert_eq!(out.split_whitespace().collect::>(), ["21", "42"], "{out}"); + assert!(book.exists()); + + let table = ok(&["cat", book_s, "--range", "K1:K2"]); + assert_eq!(table.split_whitespace().collect::>(), ["21", "42"]); + let formulas = ok(&["cat", book_s, "--range", "K2", "--formulas"]); + assert_eq!(formulas.trim(), "=K1*2"); + let whole = ok(&["cat", book_s]); + assert!(whole.lines().count() > 3, "{whole}"); + + let info = ok(&["info", book_s]); + assert!(info.contains("Budget"), "{info}"); + let json: serde_json::Value = serde_json::from_str(&ok(&["info", book_s, "--json"])).unwrap(); + assert!(json["sheets"][0]["cells"].as_u64().unwrap() > 10); + + // A failing command gives a non-zero exit and names the step. + let o = run(&["run", "--cmd", "no.such.command"]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("no.such.command")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn convert_xlsx_csv_roundtrip() { + let dir = tmpdir("convert"); + let xlsx = dir.join("in.xlsx"); + let csv = dir.join("out.csv"); + let back = dir.join("back.xlsx"); + ok(&[ + "run", + "--cmd", + r#"range.setValues={"range":"A1","values":[["Name","Qty"],["apple",3],["pear, green",4],["total","=SUM(B2:B3)"]]}"#, + "--out", + xlsx.to_str().unwrap(), + "--quiet", + ]); + ok(&["convert", xlsx.to_str().unwrap(), csv.to_str().unwrap()]); + let text = std::fs::read_to_string(&csv).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines, ["Name,Qty", "apple,3", "\"pear, green\",4", "total,7"], "{text}"); + ok(&["convert", csv.to_str().unwrap(), back.to_str().unwrap()]); + let again = ok(&["cat", back.to_str().unwrap(), "--csv"]); + assert_eq!(again.lines().collect::>(), lines); + let o = run(&["convert", xlsx.to_str().unwrap(), dir.join("x.unknown").to_str().unwrap()]); + assert!(!o.status.success()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn commands_and_functions() { + let out = ok(&["commands", "--search", "bold"]); + assert!(out.contains("home.bold")); + let v: serde_json::Value = serde_json::from_str(&ok(&["commands", "--json"])).unwrap(); + assert!(v.as_array().unwrap().len() > 100); + assert!(ok(&["functions", "--search", "vlookup"]).contains("VLOOKUP")); +} + +#[test] +fn mcp_over_stdio() { + let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"evaluate_formula","arguments":{{"formula":"=6*7"}}}}}}"# + ) + .unwrap(); + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + let lines: Vec = text.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert_eq!(lines[0]["result"]["serverInfo"]["name"], "sheetcraft"); + assert!(lines[1]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); +} diff --git a/apps/sheetcraft/Cargo.toml b/apps/sheetcraft/Cargo.toml new file mode 100644 index 000000000..2872e6792 --- /dev/null +++ b/apps/sheetcraft/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sheetcraft" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +default-run = "sheetcraft" +description = "SheetCraft desktop app" + +[lints] +workspace = true + +[dependencies] +sheetcraft-engine = { workspace = true } +sheetcraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +image = { workspace = true } + +[build-dependencies] +# Icon + VERSIONINFO for sheetcraft.exe; build.rs only uses it when the target is Windows. +winresource = "0.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } diff --git a/apps/sheetcraft/build.rs b/apps/sheetcraft/build.rs new file mode 100644 index 000000000..969f95613 --- /dev/null +++ b/apps/sheetcraft/build.rs @@ -0,0 +1,30 @@ +//! Windows only: embed the app icon and version info (VERSIONINFO) into `sheetcraft.exe`, so it +//! shows in Explorer, the taskbar, the Start menu and Alt-Tab. +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `SHEETCRAFT_REQUIRE_WINRES=1` turns it +//! into an error (for release builds). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/app-icon/sheetcraft.ico"); + println!("cargo:rerun-if-env-changed=SHEETCRAFT_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/app-icon/sheetcraft.ico") + .set("ProductName", "SheetCraft") + .set("FileDescription", "SheetCraft spreadsheet") + .set("CompanyName", "Learning Machines LLC") + .set("LegalCopyright", "Copyright (c) the SheetCraft authors. MIT OR Apache-2.0.") + .set("OriginalFilename", "sheetcraft.exe") + .set("InternalName", "sheetcraft"); + if let Err(e) = res.compile() { + if std::env::var_os("SHEETCRAFT_REQUIRE_WINRES").is_some() { + eprintln!("embedding Windows resources failed: {e}"); + std::process::exit(1); + } + println!("cargo:warning=sheetcraft.exe built without icon/version resources: {e}"); + } +} diff --git a/apps/sheetcraft/src/control_server.rs b/apps/sheetcraft/src/control_server.rs new file mode 100644 index 000000000..42c388d9a --- /dev/null +++ b/apps/sheetcraft/src/control_server.rs @@ -0,0 +1,67 @@ +//! Localhost JSON-lines control server. Each connection may send many requests; each line +//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value as Json, json}; +use sheetcraft_ui_egui::ControlRequest; + +const MAX_LINE: usize = 64 * 1024 * 1024; + +pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { + let listener = TcpListener::bind(("127.0.0.1", port))?; + let (tx, rx) = channel(); + std::thread::Builder::new().name("control".into()).spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx)); + } + })?; + eprintln!("SheetCraft control channel listening on 127.0.0.1:{port}"); + Ok(rx) +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + let reader = BufReader::new(read); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + if line.len() > MAX_LINE { + let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"})); + continue; + } + let msg: Json = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); + continue; + } + }; + let id = msg.get("id").cloned().unwrap_or(Json::Null); + let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + // Wake the UI thread: egui only runs frames when something happens. + ctx.request_repaint(); + let mut reply = rx + .recv_timeout(Duration::from_secs(60)) + .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); + if let Some(o) = reply.as_object_mut() { + o.insert("id".into(), id); + } + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/sheetcraft/src/main.rs b/apps/sheetcraft/src/main.rs new file mode 100644 index 000000000..d45289e16 --- /dev/null +++ b/apps/sheetcraft/src/main.rs @@ -0,0 +1,193 @@ +//! SheetCraft desktop app. +//! +//! Usage: `sheetcraft [--control ] [--sample ] [files…]` +//! +//! `--control ` (or `SHEETCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control +//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! See `sheetcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod control_server; + +use serde_json::json; +use sheetcraft_engine::Session; +use sheetcraft_ui_egui::{Services, SheetApp}; + +struct App(SheetApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + // Files dropped on the window open as workbooks. + for f in std::mem::take(&mut raw.dropped_files) { + if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) { + self.0.open_path(&p); + } + } + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } + fn on_exit(&mut self) { + save_prefs(&self.0); + } +} + +fn config_dir() -> Option { + if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SheetCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SheetCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("sheetcraft")) + } +} + +fn load_prefs(app: &mut SheetApp) { + if std::env::var_os("SHEETCRAFT_NO_PREFS").is_some() { + return; + } + 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) + { + app.ui = ui; + } + if let Ok(b) = std::fs::read(dir.join("prefs.json")) + && let Ok(p) = serde_json::from_slice(&b) + { + app.session.prefs = p; + } +} + +fn save_prefs(app: &SheetApp) { + if std::env::var_os("SHEETCRAFT_NO_PREFS").is_some() { + return; + } + let Some(dir) = config_dir() else { return }; + let _ = std::fs::create_dir_all(&dir); + if let Ok(b) = serde_json::to_vec_pretty(&app.ui) { + let _ = std::fs::write(dir.join("ui.json"), b); + } + if let Ok(b) = serde_json::to_vec_pretty(&app.session.prefs) { + let _ = std::fs::write(dir.join("prefs.json"), b); + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) + .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("CSV", &["csv"]) + .pick_file() + .and_then(|p| p.to_str().map(str::to_string)) + })), + pick_save: Some(Box::new(|suggested: &str| { + let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + rfd::FileDialog::new() + .set_file_name(format!("{stem}.xlsx")) + .add_filter("Excel Workbook (.xlsx)", &["xlsx"]) + .add_filter("CSV UTF-8 (.csv)", &["csv"]) + .add_filter("Web Page (.html)", &["html"]) + .save_file() + .and_then(|p| p.to_str().map(str::to_string)) + })), + open_url: Some(Box::new(|url: &str| { + let cmd = if cfg!(target_os = "macos") { + "open" + } else if cfg!(windows) { + "explorer" + } else { + "xdg-open" + }; + let _ = std::process::Command::new(cmd).arg(url).spawn(); + })), + download: None, + } +} + +fn icon() -> Option { + let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png"); + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) +} + +fn main() -> eframe::Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("SheetCraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + let mut control_port: Option = std::env::var("SHEETCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut sample: Option = None; + let mut files: Vec = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--control" => { + i += 1; + control_port = args.get(i).and_then(|p| p.parse().ok()); + } + "--sample" => { + i += 1; + sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into())); + } + a if a.starts_with("-psn_") => {} + a => files.push(a.to_string()), + } + i += 1; + } + let mut session = Session::new(); + if let Some(s) = &sample + && let Err(e) = session.execute("file.new", json!({"sample": s})) + { + eprintln!("sample: {e}"); + } + for f in &files { + if let Err(e) = session.execute("file.open", json!({"path": f})) { + eprintln!("{f}: {e}"); + } + } + if session.documents().is_empty() { + session.new_workbook(); + } + let mut app = SheetApp::new(session, services()); + let control_port = control_port; + load_prefs(&mut app); + let mut viewport = egui::ViewportBuilder::default() + .with_title("SheetCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([640.0, 420.0]) + .with_app_id("ai.storyteller.sheetcraft") + .with_drag_and_drop(true); + if cfg!(target_os = "macos") { + viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); + } + if let Some(i) = icon() { + viewport = viewport.with_icon(i); + } + let options = eframe::NativeOptions { viewport, ..Default::default() }; + eframe::run_native( + "SheetCraft", + options, + Box::new(move |cc| { + SheetApp::setup_context(&cc.egui_ctx, app.ui.dark); + if let Some(port) = control_port { + match control_server::start(port, cc.egui_ctx.clone()) { + Ok(rx) => app.control_rx = Some(rx), + Err(e) => eprintln!("control channel on port {port}: {e}"), + } + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/assets/app-icon/LICENSE.txt b/assets/app-icon/LICENSE.txt new file mode 100644 index 000000000..15da9c41d --- /dev/null +++ b/assets/app-icon/LICENSE.txt @@ -0,0 +1,13 @@ +SheetCraft app icon (sheetcraft.svg and every file derived from it in this directory: +sheetcraft-1024.png, sheetcraft-macos-512.png, sheetcraft.icns, sheetcraft.ico, hicolor/). + +PLACEHOLDER artwork: original work written for this project by SheetCraft contributors (simple +hand-written SVG geometry). No third-party material. Licensed like the rest of SheetCraft, under +either of + + * Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * MIT license (https://opensource.org/licenses/MIT) + +at your option. + +Regenerate the derived files with packaging/icons.sh. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md new file mode 100644 index 000000000..75f313b7f --- /dev/null +++ b/assets/app-icon/README.md @@ -0,0 +1,52 @@ +# SheetCraft app icon (PLACEHOLDER) + +**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the +product owner makes (craftrules `standards/icon-design.md`); SheetCraft's hasn't been made yet. +Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number +column, a selected cell with its fill handle and a double rule under a total, on a full-bleed +SheetCraft-green tile. It is original geometry written by hand for this repository, deliberately +not modelled on any vendor's spreadsheet logo (no letters, no "X", no overlapping panels). + +When the owner's art arrives, replace `sheetcraft.svg`, run `packaging/icons.sh`, update this +README (provenance, creature) and the rows in `ATTRIBUTION.md`, and drop the word placeholder. + +## Palette + +Exactly three colours: + +| Colour | Hex | Used for | +|---|---|---| +| Ink | `#0b0b0c` | Line work and contour | +| Paper | `#efe9dc` | The page | +| SheetCraft green (app colour, **proposed**) | `#1f9d55` | The full-bleed field and the header cells | + +The app colour is a proposal pending the owner's approval: SheetCraft green `#1f9d55`, with ink +(darker) variant `#147a40` for text and accents on light backgrounds. It differs from +DesignCraft's `#7bb51c`. + +## Geometry + +A 512 × 512 viewBox, clipped to a rounded square with `rx=112`. Windows and Linux use the full +tile. The macOS icons (`.icns`, `sheetcraft-macos-512.png`) sit on Apple's grid: an 824/1024 +body with a transparent margin. + +## Files + +| File | Use | +|---|---| +| `sheetcraft.svg` | Canonical artwork; also `hicolor/scalable` | +| `sheetcraft-1024.png` | 1024 px render of the full tile | +| `sheetcraft-macos-512.png` | Runtime Dock icon on macOS (for `apps/sheetcraft` to embed) | +| `sheetcraft.icns` | macOS bundle icon (`CFBundleIconFile`) | +| `sheetcraft.ico` | Windows exe icon, 16–256 px (embedded by `apps/sheetcraft/build.rs`) | +| `hicolor//apps/ai.storyteller.sheetcraft.png` | Linux icon theme, 16–512 px; the 256 px one is also the runtime window icon on Windows and Linux | +| `hicolor/scalable/apps/ai.storyteller.sheetcraft.svg` | Linux scalable icon | +| `LICENSE.txt` | MIT OR Apache-2.0 | + +The app ID is `ai.storyteller.sheetcraft`. It's used for the hicolor icon names, the Wayland app +ID and `packaging/linux/ai.storyteller.sheetcraft.desktop`. + +## Regenerate + +Edit `sheetcraft.svg`, then run `packaging/icons.sh`. It needs `resvg`, and `iconutil` for the +`.icns` (macOS only). It packs the `.ico` with `cargo xtask ico`. diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 0000000000000000000000000000000000000000..2951436a14aaeb638d5c50525e233c065efbbac5 GIT binary patch literal 4785 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

P$I6( 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 O08LdrmG{c9sQ&}Q3hCYe literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 0000000000000000000000000000000000000000..69120d71a4ac80afe7e26a0d9d767fafa652a1b5 GIT binary patch literal 1828 zcmV+<2iy3GP)HAz2;e)O{+ z1q!4s(gFdp*hrj@tWY*@Nu1bnBFEVz#fha@3$<9XWO3(^Gvq9}@7bn}SWLvMR~XHk z8NPSUJMZ56?vrtyweQ~(gjKi~w!PaH(2H--1RFyTgno#g|Hk(oNLSuhz#skX9vk>K zHG}weElr^J$M1ROK!v~+@csA>jez$u5(1Omw$v$n&6(Tz608e($X)fYg;jM)3!-Yg^e8{6!IFhMsde^t1}M-vgH@L;olV{H6n|Fy?Rf$>eaq z#C41X(}wxR421D4gj^YAy@XIh2$7~nG&QuKD;h&nFSQ#R_`61aZ-6L@_}2sfN4BWq zl>>vY_H=(a1;UnZ@OD8!?#(G2d-h2*HiS_qYDgwiNYHilK6har!y{*rNTl$`d;Wly z<|f{?zCMVq@CN+)uATV(zCT0S*8?%^u8vRG@(nLVx_mN%!5}8%GkEyXCurah!dI?f zpbib;dUW62$?xpCYOI||B(d#BTXFEA2l1!-{|fVGQJ6j9swCu z82EG;>({Tvty_Ch9|~1ea5@#3rpaeoq~*!0Dss6za(NYp2TtIYJGNjclf+Cq1zFNy zMqE(HuWU)Eg@6m=qm!8Ej6;$n9DeOM5OSiZ7BRj&52I{gdUOiDo7cebh*-4qfJ;Vs zdJ(RcC=hc(>>~dbEcgSRC{g4HH0J6F1Y9^XJBEQH|3gtWVVqlr(%THN-VLFdx;P|g zx`KSC48gC^ATAy~Mm$ZCWl-BfdBK3K8IY$7usZyFQmZC_APrcj!07hDL67!Qf&PI8 zF9XRUY~6q-ZKD2c0ir`kMDS5W*|78m#Pk?CTHCQP5`)j@!vhaJj3G@$HLJP=*%Nap z%{%xkq*MtzeBFqJqUeaOL1UmE9|uPfrS{goUib-!`Nr$VPa+r!qHoI&Dr0Tw-h}rT zPgR*|wJ0D3w44UlUF#vZ94L=1<99#ai67q9SNRQ_G5ceoS1Oh9*5JGNkaC0KAmz$s zLS+4gy! zekabI;qo(k@#Uj<txnxR>@;*B>?A(PE=r7p~0gmxhXrB(t0#l44!=MCEZG8#t4$8hr0 zARkm!3rJ-$n4(m@zWY`#zt`iz&v$I+ge>a6>85V@{XV!zoyVVj9yKc{X z5Zn%=Qz|)_1MO?uD7?IUDYBUy{&Md=o~*c0s6KMwAa37wTV+|8xjZgdD*2LEu0eq& z0tp3x$mO4eD64JWd=rJ7xAGe=k%-UCK_XvZs^oGxzGpoBTt6qn(ldL{wp=Z(LG=1o zPe7zxpid-W+8#hRF?TVK7hgIIS(YpL|D%u3LDx03u4&<_Fv(e+5Q|39y|JUB)X>Bj zhVnYZ6>zFA0TEEAX8@;3xj=@4!jf%Dn>KCagEFuq$BtuT*9N+-RX{9ES#D^KH1S-) zh&EH=vJ#Uk;#4I7MS@7ca$gI7T*4(p$l2DmMftN%l@g1PPtQ-HOq&w}V#*AUe1bFY zet?q$gA{@VPBfR!!?KGt5@0G&t!7SVcse#_GkE3DtN3tegoCjhkW8k@0YzFC2@Y~l zc4tfAqxx{yojbTv%%1N*gtrgxubqHNz^vNkOAzrP5D8)T-Mdg%=i`8*W9RY0OGhw1 zF~te7Xh>3-w!you0?v#TFusBaZ6yK%gkh4hXC(sVfDHHMM&7oTi_ioU;c%$(oq;e< z4^WwA7zPT3B4381uO7!cBSYl4i%2Xdv3PL_N?+>@<1=ig0L2VeBp?N^?cst;!kM>+ z@Xt#R;@s>MQi(K`ZHq`QCg6_*sV;Cr33`xrmMDY-NJ%oHO3OKPA|Z{CPYqTL8Zha#p)nZ!6Fi%7;y4}e~h;jH}>Rp0C-J<2gI3?XKpSUmaK&2J<)3^PlUF=br&O`0Iic S@=`MZ0000N`Rn< zwn8WeEv=N21_Fu-v}#C8(uAA_QpZW`*iL+GeC+An^(o~WRnm7BZ>w&Lt<~E zsW^x!K)NFYl?bZ77}9+)(Z*!0vI+6_0Fsfo-q*UM428EXg=9D3bRo&Ozyu9H%-2y6 z@(mI?<{QywPe=&0^^SRFZcP-+k}H=x+|8|2KQ#xU&|r z$Bxqiac2Q)C+g#WH8&-Y-`Anqm&IDeu(mI5oTxq3C8eEdPUiQsrR+1`ovj!t~^_mA

vi1RZ0rP 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 literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg new file mode 100644 index 000000000..548a27291 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/sheetcraft-1024.png b/assets/app-icon/sheetcraft-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3ab35eafbc7b35350d5a0f6d59f34327b5dd563c GIT binary patch literal 44149 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 Zz_$bpW-z`6t$=+%=4RG=a&|di|1Tj#Yu5k( literal 0 HcmV?d00001 diff --git a/assets/app-icon/sheetcraft-macos-512.png b/assets/app-icon/sheetcraft-macos-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b6b38b9b0ebfd4401e5cd354431e50909e5ea86c GIT binary patch literal 16994 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 JmnfMB|6j(>&I$kk literal 0 HcmV?d00001 diff --git a/assets/app-icon/sheetcraft.icns b/assets/app-icon/sheetcraft.icns new file mode 100644 index 0000000000000000000000000000000000000000..29ce001b653aebbace79c3c52d5774b802efec2b GIT binary patch literal 145377 zcmdS>bx>SQ@IMOg!s52LySux)ySoLq#oax@2?U4W?(P;exCVC;f(G}?^S$zWtKM6; z>aDu}ytOquXU^$9-P6-E=hJ;=*Ur+}9RS6P<$y+Nc0~kJz;Q_#CI{@fEl8-|0Q2+qw z0w4hTqXzzWt^n%)x++!x{lAX?Ba~5>s0RR$@8qS#wS0hQhDeaFOW1FB+xJH;znBoH z$Rm=#(z79-OTt2O*XA=ts$m>lOVuhpOBt}foRvebs#FE)SF12CkR`O(e@9ODvUhS4 z|B8qX_LadPJCNS$${wY8KIiwo-+t*j?fdm<0cSlYbLxHj`M*!D`+3K4&NEy_0RpNL z#4HRNlj!07FvPlg)~36D???=McoX(72{zL*P*vcSi~J@!#IGje$-T;p2ye1W=10i< zSyeTB=SmdVKsH=|lw+r_AyE^03P&!zj8La!hN@K>s--*hfgGOlE#- ztNv*z@CpqhtwbvA%ClrWy^iA2zv4|H)$c{C!||}=Uk;{lTpgi0G)0QdB`H6JvYtQ+ z4V4w3?okc014aH$Cnu5|V$1(YN!1$DBi2v)Z`ir#JN;$v6A8OxK?z!DYmn_VrU(QRlh5w(d%isX zK2$i>1vj*El&(X*%Uz|jx6sBM|7XUa|IE8rBi{JOjOAGyspKnXGz=zkF?td8Na3=q zHZ;7y!nu=+sSgV0nA3VN<4E#Ap2#bCyGiM3wRww8yqbN?2F=^V8jFpJbR!wXOjp$Ckfp z_W7tZ+tCz_z$Ok9OZ+9>qRYAxOgj+XL@+f*yTRtMy3IIWun4K;BMjor%z!u6Y@w?} zZ?v*;V09P{gOfjtRj-)U6n!IERiXPfj{M8FtS3%7YAc(e69R-eIsaKBp@joN?Pi8Mugc8Tg&Fv%g#%MB;; zkN!rxh&RS{h=Lhbu9^4#9z|-K{^IW(V&|1#;O~XJ6m*=`ynHzi(fF#5Is@|{YX>aie|B6`^7DTeY@-jVCx%q z^Jw?oXrCCxadgRwM9P}?4jEG>jt5`oE-mcuMrC-b-=R#xIk2PwWdi2iwSZ239yxuO z8^%yUjBif}pRwE|b)CcC?ENLozC$%Z1~Ej4tgH->cN$i0-UrUl@Fu?Qw8_nWBHTr| z?bouS0APp6!@*LgW;#nW2T^yytnFP+gD;%r^kF0Xgx$VnIK&7|o8{vUn7Amt>LJ;& zht$;9E+TH@PKuqEK3qw?#P`74jZe~C#>N0)pDlp)P~=dD;`Q5%{r-f;@Zeb@w4U&j z*^8n^Stjg5SaR z$oV&1m_>!s#H4iZW{)1H_WEAfL*c-V zt(Pd#sxDbL+`81iB+N``v+HhIjDDH7?pJeQeR1JVBIlGywF{Br5i`yEQaK>x=o_G^ zWj}$CUr{sE)K@w3LU>GHRdmnfw8g#{BP>~Z1&Nxs4&m7j``J|&pUFteCBY3;Vi`-U z_=e3yLu+>u#)UQtiir2T>T>q6r9e{ZP>iK8F%kk3W#^(|RwS0jwdm7?Xfb zM}B8u*bSB>jM(zO(c~|CDk>711lYlyz{jnhjWnRC3?5$>wX~o}=GBK9di*^$;~*G7 z&X!V)tlMaF^h0p!+YONFJcq0yn?WR}fxpv`>+yC@Tz7}ZIjMxBlMHApxSkOLdRAAJ z`e=k2ucFzzGffP_Qc@_eR5IfNpWkD%k7kBc^EZeVWLT<$eNx95#t$1z7OX>R>5%fE zl`l!0kO(E#8u`*nJRdKAvgXsW!ARJ5*D^9rM9gs#AU_N@vhKF3viy*AxKR8pg8NPT zyXvmh2z0mWn^6vt$FOcQB(m6v%P4lP&lizRCiqTHE^Y6c1N>7S~ zB=aJ6I)=JCR>)HumF$ZbY=4{=-3cu{HRBgdaY_pEYqr-;h{x-EQS-LS{kJ9O&~>Yk z-KY>TANcyp_WKqKMg`r=`N9xxc56#sk)BA^BZd4Ew7!399#PG%9M_R_5nb^VVB9(? zXI>{^y@arkJGkSs8!>eKuj)b6`tMz{UVEwTw(L{@$xru?gV(#g^`@>7V;L4PY7~;z&Dn%y-g>0X_KnMMk;`@s^K+Td9$4!9ZV>Rl$ z(Xxj$Av@>Ke{;P|-_Ax>ut0V^kU092Tx=k&WI5u;7o6e6Nf<9LA4MgppOm}E zKZNfe0|rC)&Td}ORMd1)pf+^@(0tWt-B?rjQ9Yco&7riFGTjMjGyIfP&xnl|kcJAF zZQq0E-=z@GtOGW_r@LdDO zHx9P8gSm`-p(N4J`pt**!XkpwQQe<#wi^LyoRdF#IL!7!TXp*AkRbwFJMC;@ipZCX zUJ?tZ1UwN$sBL^#nKN}%vSTDbn=kE6UJdW0fI>gxuZNW}#EU)SJ0t8ujGwNC&Qiq5v)tvLcHMF>Bb=RB3{ ze-eT#Slj#oE`9E5damis$Vxn6MDu@}4fu>}%RTdTIB4xzRW$dCOONPyM4r?n$ha?v zrA;k)qoA)Mrc!hgkf41)#%kePHL_#yDc&;An8zTAEi!w!6LkXtl*UE9vmv{Bqf^?W zm%xjl>aKSaK3l?5SH4{j0oMdK5Vr^4{PI=;31Zcft&oOgB{JFWrECm6Wp%;;U0gla6uNDYEZ)R}~#A=6v5Q1U;HwZ%=Go20;sU zEEJQpAz$SBdq&*9=dcG!&6Zy@LxmH76mwxx$r6PpM1ctcEBiR&+-1cxEHbMv;EV-R ztA(=IL`s3*W2X-loRIr2cK>r`c-Igxc~4|>HCqm2JyR8caQD-&-eO*=S2<>&F1^XKYlTWCM2VE*-FyA4* z$Cq$qO%s|I9RX4*3#SGp+HrMKuOe!J8?B&sOAW$LO1E+1xhb4tjgE65xaE>U7^d8T zK$J^PvNarhFzL6`xYmH!;&iTzToCQ&@l`=S8m-Oh-J`TfR*A>x0kQDL5}R z%&vBxDKxqVlzG>3JHC6A_91Zz+`xf;hYBXRj+x+ng%G*31}f}Jp+0nOFfL799xvKZ1?-Numeby3e#ZKr8G0{~{rIX%(qj39~ReOEz8r02}82CMFB{ zAPf1Qm~2SIeDb65|4K|2^8XQ&{U-q+mjLnx0I+QSkC@EZAx?V<+vIo}06m1vYD$mC z%Dfo%14=!aM*Cr|QoiZ|uade#iQMja$fhuMnPHVinH%Wvz6v6G5f1=EJ5}mDOI9|( zmxOVdsTYPL%Pl33?yx~~-g^W;Pj@yQ|MWd=y6?CrIyn7w)O}~-pMSgNP}6hQA>}*H zo@7ZjDwYsR1kDfYhJ1iG_WyXIcLEPGAJAd3UL#FW7W%Y-G*2XJAGjhI9lMctw|u;| zKp?4%U$DB&k*zR&cF>7r6m4C0JFkDb;%Z0BKW)v>KGWRhysBTtz8nGktjgM(t8l)_ zKX*IEkokQ_Xzr~EH*e0t+@z`!Sz5IulT>PLeZqEL8Yfb$6e1d} zUc=b&B5Z+FeyBMJ?RF0l;BqI z*yCmdJ7keQ$qdThdoBwIi`+}xv8I|ajJ`9$GmydG8uh&gMXmMj3;O;PDQz5(9s+gW zYF5J~CUUV}xZT@RBWzX%lu8{Rt8zO!YVKSBO9)k0vISj}POmRV5F_ddaq zY%@)V*{lyvizPxB)7;IvU@g{tf9vcChE=<54 za@HvVA9Qf<@`Lzq(o?5~U~%#x%+p4rr(meB$?M6Wx;gnS~wEor4{VgEaf zTJj|!VJXjOb_r-msMF<|;>?2Zy|5CY$;+2+lrBK6<@LV~`c)W;J`QVkGr%)wdT>}A z7J%W20KRkXf)W3oK%NUXOp1u9*mE2i`sRHRu;9ObpA|12HJPR(`d0p_*#9C}Z%g1+ zoR&E7lz9N;ZtsXIxAyXFw8Wju3_x5z=ZRP(3%~v15h*9mtW~l3&rAPF05v@}z(|e_ z-o?P|Y}@Vnc5O;sw4AB;(C?2p)AOTn3i`8PmvpxoA@>#^C3hE@ZS~3PB3^;B8)h18 z82d>GsVkVXt{RZlX|8-1 z<0h#!ZZoO$ZDDOy56k-2=~wYwa$j+ko+++vA0)~RTdpYtEPrtbu7|8nHd)mX_&bJ1 z>))A(k;QrU-Yw7djCep7LR(`E;2#ysV^%aZbw3<%B^<10@|BJNDj9!A$2tT(SXv_G zm!UkI;Ub2$z+?`akTsOiWrjwD!M^msc+I|)80D_=3X=zx7lt&=8YpPw-RbHjR98?V7 ztH{I4xH?Lxn3Gt}oe88x>XyfmfAk_wEXPdc7N=eFL9wbI< zaa4mV&J4zPATn=NzGw~Shb|ve5}?ySsnA||NJfl`*^(0vNFDqcY6Op6i%?y>R$K{0 zw1l+;sx_Ir_tY%9Z2;|z$RcVNISR$Ou*zL6Ve#aM89ndSiJ?^)d=)ve5SU>BO zKCO48mnd>`CI3M}fZZj;!LrJCePjR0=hW?q58D{mq@iI2z(z=nS?w_Bc4vOE!Bew@ z(_*XAY*Ak2KPDp+TrD-L_*R>34LzvvMbGneflnJYDZLR-Yd?{t^vS~4w1T1K#D{#q z3;JooD=RX!+&KK2blUF_|nE<>*-D-|%V!1W=e3jZoAt|j_koAk= zCf=}An@0OeC?8Q1LctwX7MU7?a}~nXtCR-RWBwbLCk&gdW{;KXJ364jcuyL0Y6{NB z6h3H5DmT$w(0jW3>4H9$Z6NP$cpF{TZCo6b%K%+(aE?1)C`q&m)B0U2T}PzQh00k< zzDA+q&+YMSyPfi)kT+XP&I;+E#QaY{kbzXB>EyK*K7f@$bBL?WI@Ek44?;Q3*z!A^ zH}i&+q3UqvA^@f>8e6pD@D@%>#@0J>Ol#0jEMrmt`LZ9S+OU79!D^4}f#zP*29QZm z+93hEXNh|8IQq7#fpkEc&-PiM6HWcg7_?M|zO21+62YR3v1EVq;DUkLtHFk9F7Ptt zHQ#&klq}ETP#E0@e`A*41Lkb_i$6nqY>I)l#)?pniNdAD0N~^mk!=do7Ao~FgT&UM z1Cz}yX|#wxPO7qG8c=`kzx9%lAj}_vf7nv5qn0sC7S0P_*k3rZ(X;pc8UF7-SKKdr z^uyVq#UA*6pI@M`svk1Hw(oQQp>)n(#0QWYwDn1?>im!~Px_RDp)SM*fVT@*`Z*MY zFQ-0ciJdpRhkcq-a34n?n=vYa3xE22WDiP9Tow6c60V zFBz($r}@A<0qFY0A>wdxJRL;KiJ?Q!r}|Fw89sB$YL(e0dCXBw?|ZfEh`%z_UZb$krDQrw_Y?x`K&JQZ}(Uyt;c>B zr>oSeyCz{3wOH9ozgWz*FWPoJKOa+KBD2<5o*?(LqPE!{R9`=ET$W?9AB=F&s_LCG z4de$d%lmI{(~`R2%Pkyr(u^jcSq3Lbo;!x&4kXK^2BkWtBEW`p;g*SU>$<^3sG{CZ zoe)6uB1c0jmTwi?Q76Orr%W?|)UX}}pJNqlie}%t0 z5&;IP@yE<}qv8}vOh}N(8byzwiUGeF1=dfPxRKY(9zNM_2MjOe9FZPR&H;cNieO7y zYM8|h9v58X@CC{^8XFVlGh7|^_KEmaw!VU$yr?sF zOyrPn{VAt-@mf`UG6l?TFKd>5q08`wcef6s_iWUz4f1SOc&(#KYyeE|X+-=*U!y-T zjbdUt1Fq&bx3_aC>&}JKtO;YriXoid2@f2rVnSlnQ3Seb-pu}GPf|Gg+(TxWY8s2Na-i5K)6*MQ7f{Vb_jbLc~- z`txVBe6T2uDXVZO`U0(d_dgmHZo1)tg2O?&YgliO81s3VNF*9bgntx^ok7Rl_P_6| zm+wCq$L{EJ`J^!~a&Tjzrztd0I>%&0uyDPo1S4|g*M&{TfTDR93d zFau;^VHWy7hd!;`N_9t7uCbgg*8lu)7i?J4eE;O>l^g==tc`KV>ih9uKG{5()gG;; zFpqGF->m;T9VKbLL81lV0t&q?-aXIB=mBdmT&v#Fg?EdB0p_^@UN zze}SYt*}b7=-?dPzPnmzY7%qY)vq06Oyp_{7bN7?EE(m;NSI{vRyGQMrEi5G&rg zjYj4a>F^15A+V_F=ZOeVGy#B8tr<2)!6eofunFQ(dut^o>q{Jm6=qHEO z1qByZw~oSeoZ-tacAh_2b(0A^QA-=>yBTaxVng`#p^N{ABgE4bEEqu}6oZL*B$bf@C#Zj(m z%yhiKJ=sX$GQDAC{ZNflzv)nD+HLU05XmlL(gI<-c8C zO?LT2mqwzzvkd_SR308M;X;ZJKGPAxPuz!4r5j+ICz-JUsIucFZhcqzxe%Tj=_{Az z7`d6;kpU5R=R!&meW8QTP|3u@jG=58k8pTOt#;5{cp#YTz6n70a8)g-4|71XA!Nzh zH3>;R2$_a7>Sa>GUhfDGu-xNcDHYFaMD&PGorfsKmX|MiDSV3F_Nz4zuEtNp7CM>c zTvux-p@c;C1#j_)_sv(PEDE13gp)2SXtl9Bda=GYF;NDCbQ6M;Y$uc-?e}n5rjZ+7 z?noKIeAXBRQM4eJz{1OErOsTee`x-QeY_uz8=baI>}I62oni7CSIj!ehU}j=+-b)> z1N%eOqbPI{e^{W$DS`EJQGLWRBrc=rrOL`LzdJ3`ed4Gvzp}&32Cf1ml|5cD&WF=`6Ma3wW9Tr@nEZa$DR0TZpBR7^Tu%#slTi_W6L>65v z?)&v2N?9DQb|70{$!yeK6XGxmIIAJ_!dYRdUE>0$ z6C6n+QrV17zuT5X`L4?tH$^lq?yZROqb4UU%Akn<CSQeV`!3 z3v%jO?xySmMVbDQ8eon34$G%#Bxex{*S%_hoTASMN3oo2jY@lJkQY+TFfwg8V5495}mEZ&? zS{B)2kW!1hyoe?V5v2%Iw`qJv{ptKdNi_YN*Cwr0go|;@b_vY~TvKmF zBLcDD9RBRrqbG%1zUEEVTdInxBwbtI(N@Mct6%?@nL+Vcf|Dyo`3=`6KbLrAY}egi ziNkjq_2s}N^Z%EWog4D1#m@Ws_TUW537yk_smK471ZC$00O-g6zmuT|@4A6&{D`#zB8aEv2k29J1xC3j?SMQu)+8Ibu_0F;Wm{YR+B;=(Mn6DaC1a`4a{N ztjob{h$ob-N_fmS*uIf=+dWOe&s&~dTS3n`{xkf~r-35NDqCI0mceg#TMlPE%WHq> zsKvox5C|wP_Wuu6H|6YAo+S!L&X!=4`fb;7Wl(UD#;e_u`a$C^yJqE!SFqt8m5rGbb~e#A&-Z~59-;5%HDmzmK}OGx6HlH$2)*)_4I)4^-wkI_f;|*_ zRp+Zd$`IPR7??m2@U!^BQ=~%BtqF4LHFMHk!&?PYa=rrEHO*2$CXfb{-aF6bydJ7n zO1G43tNZz~ii0o1#meeXE9qNQE0(hGP6bbBR~|aJ!T=eu+lK8b?oWTYi?iV6z#s)l zzOIv;O(wJ4Q!`5gx+N0Ca=otWc-~tH&y9v52Ap2NLJswZ5RNuY{@x& zJvMm64neF6RO!jhk)((sCwc3s9vB8bN^cfY&@f zDeqLK$4SyE4GDR8?42jXNDhr>@GvkY8%8#Z z2x|UOfP*+9Dhkd8^tYNCstT9-8%G4y;62TckwY19);~=(M220p0iMhfm4Y&{n5E2m zPMCL$6c@psFl37F<$F}yEp-TnkXMB-G+-li7{;kr z&kl;)M5v%EiH}#4m8pJilsP_6FxTt;yX^*x!LR$Yi{p~v-bayd-fQ`JCCiTf(KN8G zQG`oHgMDCl3zbuNP!0y}kES=D}iw-Cqs;3{oWA=CHVW5MUA zT53>W4+hkk$DtF|K@j}#(Hf=}yZ2X4mdyg4-~*^+Bt3ZhKH$?Bgs%^1t_uik`C;$k z;PQ8{rO9j7>YGVn0+8&0fM6mI>u%AhK1Q0))xnl&I(Qq*0RHoi%vE8_+0~Y1h$WM6a5v zqtd@>^C3eXYX!F>Syn9@qaf!}F6DS4H$t#8%M|Lh9EO1sd{DC& zPL*Ldq^W_s12%DU3g|svA-(d-wK`B- z{tnIOFOp=r+{3k5>YV9rYLSs7KUM%kv2DE9dEfc;N7vbpyZ;KYYBmT zHNaZi&pJq=HKT?MDYuos-V5(kLqGlgQD?im!|kG3X6l!~sWL=bJDP*=9Tiyu@byGV zSNsVLbA>}9wWC$FJ1^km+h_RA>Gj@XF|bSbWBM?KN$0aG%z%U7A(W;#o;4oStmZGN z!C1+{)h`u9W%FypEnmKOadEdYd)32)=mwrD6p7??`1uz?z-~Yyj<6oFDpbtdI3ly< zk=gse4{r4ZZMAn=V#Oa+a5nZ6ChPZPy)_tISAlEa7d7-oaMj0$noHP&y^!(lW`9{=kR@kDxtu5G5>#((RrGE3&HS7 z%NM;4#~6|%*M&Qx@a$)oi}fUT?%*$^w!3CoMrK!IVf;$XPeZuPk}P1`=?HRwSE6jE zY9o)kYpI+zks;HGLJkHd;=NkiTtv_ft@9s0jS;!%zz50gE?Rnp+T#rl$UHI`Zo8Zd zvDeGK(=-(rRe~jiIh2UTP2%gLIT_UK9F55mH&A6yDRYh~!%U;rxIV*bT-`ZFj4`RX zu{HRFpot)~Cxn@^zyk?Qq{eK>wR9PSONV7v_iuTR)f+51K>Ob-+$h$;RH>4g?^bUI z&&>Mh?N8ado8+lRAu_|vPxI>t4b12OP-tpPttS_EFkssG)k)7`rM{ThOP`oDIDN<}@EN@&Gwc;EpQ2v9*r6k!Co za_QTzEx|{*Aj1twMdw#Kh_E>wT1E%#r>d}$p0Od=tOcE+P?~4wiVO?8o^@s*bkX>c zgfen*sm*Pb*TAJ7KzU@y<-~m=jIE&r$X~|oEA2c`*{Mzxja^ql&M2Jf7X}x9p{_0j z?*!_uCXQ}VPG$2TE5-bud~uQPSl>NCW{1AcqnP`An5XyG5uL7QLQYBwsj}D^Uvcv& zMaN+3F?@dS%I2v57>~hBACCS{lH7-+)x@p0C06|JSj>{yV=bwdSGV2e6X>t?#}0x^ zH3od`HW!=I_TnsT!L(eU8eye_;UBm4AF@uWUrZ}P&jjA6l%hf$mOzuk)j&g5JVDp0 zk)fxzZc*Q+rzw|w?Vh~#Ipnw~0Zn4hHEL*GYyC+l$(LpHkm75AG{5Gr@JOw+Pa|Kn z;g|3STMhdlnk*~ER?CDNYpAWMV0&DB?tfm7v&~FGFN7ZsN?ZT@VbkWGsy^M7h5Y(G zax|O0Ki4Lh-jaR$D&GfP|zy74Z^pm$SA&U#LTgVig=(%PEp z+MqE~&~2%K83H!(Cx6_Jgf7J=@^L$8&X4xihOa&!%q-9#{P4-aThYs1T}`i0Zdh;U zd;k0@ca9HLLj|aQEt+HsPszSJI$SFX6E<$2-u9p9{P!!V6eeP1DG>s5+z6-%7=qHO zPXn3#h-w*GP$CJqcwL6BFme2MpwERuBg%ACiMRE>#psp`SJ0YN8u>!*#%q6I5WhEQ zFl)sQzhpKqucolRysd))2x!{E?w>Lz%*X0Co;T8?E@AwagXU97=qlulu2&q#u`q30 zGv2dypYPNy=_K}Rk;QE+rOnvM1m^H;NCzWCsCEEQ?;=i);piUz83;P+!1Ln0thvSVP0~jc&%SYB;8=dY>UBV*g3y|AnQBbY> z%-eLPw*ItPZsgLbIU|CY25EAn>Ab&CG(7{;Y&~(o5R3Bun%x=ND=J?qPN)vL{!;M+ zUL1(`KdZ@44!9lz0p#hK%Gad7NeB)_QA%hcMa2=EK_7xQ)abOaSIp#Zt0vv|);5x2 z+j)LTeOGs-Xt38->oWy1?%FdTfG=61B_}1<^{fZti?$BIl*_= zA0J};i-gNpe+XlaBDkN5Uuu)sL*RxrP6_HOjoLS`C zCR)(kzq1?mI5ROhgmtx1SN4HF3m0Du&ftJ_EAOKz(yOoPCC?v%vIQSb@bX-c>4nw? z4#D4P$VL;9^aGjt8a@vwKso6PaU|7&L)kAVYz2M%|N3oy2$PopZtOOHF_mqL406J@ zj4Pp~Aw!_})23F$L35N9(_Eb6kniKSQ@N?+COkLL6Nt!eIYBYc7V}uU^f4s#`MGAt zgqDBspX)KlRYMs0xIsl8@!NPtf~8D@T)koM2Fd`RU^6iu`V0gy{J3R|r=VlHR{~%5Ys&fuLno*eAJ~}dx4k=axSXDF&2^-D__YRZK zVt)|;9kAC^#O`TiNsJ#VG1XN1n3RtIyq30l`AjsLXG=LsGr z>JT2A^8g2)yxg_d9Sm%&uCe1Sc6C#I^QmbL*J$B5{8s#^4lDivj?%=Co@5)M&}B>j z3p20(?RQoIRE<1`T3C1DF;`coJ+&j_Sidu_TH`x7p?_wXv?zg111d<49Z`cp+pW+4 z!*xIRSUjYYJ54884m`eisqpT(D-YR?oN9TD=J4Kb;e5a#8jiTzJ^FO$=@mcO6%Clf zw$PLAJW)@xsi6gQQ<{k=l-)hJchaCb(R8%Ra7Hl*pbYZWD);`OtE=wwZRepORu*d) zaL^<(FWR(rCUER?*bn@$N&e-|;-`8EMUU-e+dHzR4xTNWO^0>g&U4zw(@aL=WE)#J zn5S;s&C#ci)l4zjMHsjP>2})z_ucvlU(YcvN&88FZ4et)j>6b{xDkmTR{SKtCDwrz z@e)5Q9^%v{O2QFnuNMpn6A_h$qBf8x6Xk#javeP#BYA(l=%IKZ$w3;la0C{J?*O+= z4x~c+t^;$m5?rd}wr^2$O+OS=)&Xqu2SyrPm9~_k!HNKGj>~Vp6MRlx%0m0|n@q)) z8;>BF3Z4@M`7%*QPzy^$3B%%0^3dF0JFNz8slvYR!#cmgZYayax5X=odXNv-PB_L) ziU?&tL9bGBju!Kb`BR#6qE@o)oW_7z#d0$zzkRrH2Y5{XW47qs6L=)MhIH^)Yyuoc z^^I?|Sm~<%7YWcCOLuB@z}7vfVTS)A43u=BNNFXkum8#>Ce{idfOBq36c#*t{DO)R zL`FMevWOEwydcp&``u9)H{6ng`+GjS7afk!HA5gAX|eIk#P;3SI_C zRt?xXx{Bi!Uy?)X5d9?6csnGM^FbyKULG-3D+pbBr3YxB27a{s!93D>=Lj-CNYgITlo75> zDgy&Hk$Tf#yrS10qjbW5-DVz$ILV(`x@|qzz+t@wz%}L_l;O^ZJl~!i^}X*k76u3g z>+6WclfVP(1&}tr%ZHMV`F>!pIFeo!Mf-DXcnRe(?y&%Z*RNE+i$pvyLUwht=~S|V zBLTm+jwi*pH$uB$hk89XLT-ie9!>C9ICWj_jit_CjEu4uqrfC?iX$75d>Ee@F0tq& z04nC@wG&#)EV>-2i=JFk`^0~aU=!GK`6;UW-*HTwF3{hZUJ^sa`2-i zyHw<~16f^NRac9+sHx2(I*}A8#DDXs0vGn-fp~Y258`$*ND4GRRRm=y;flk;R?p|~ z`4*4pWy?g9$YJCSRB~(KN`qE#i1`ep>$TA++?>NwUgi0p0L1BA|5S^&Hwpw?O$8O= zO@2Tw?*@YR@2F_4dUNuj0hz5%N*jgGrKAKWVIs4>rDf|9mp1Xb)AjjdFvRfJngf5Q=9;RkaOr3uSATgk{STQyVM**Wlu@T{KIQIox z%#pBSZhmaomwS_cfD;UP6jbFe-@b0i73U*mI86!Blq5?+76*uJjFsk3jJTA$BizNx znZR`{!|j41fKYa|13PRH0kG9V?@Xy7LNK1XHF%6l5=Q@!vFvw>Qe>b+Y_}TUzn=gp z_9+0fZV~YEQfeRaXK_E|M-Df*c3BJwv^)(3TPGkqtD#Z2Pyl*S50I;JpHfG5C6|Wo zkTMK*Q%o$@vHd62H5L$U7*WNEz;`BkY)1nkG<}rMI>HJmc1b;w@N7n&;1x6I11p$* z8M0qz`<7WE(5)!N3|u2Zz`i7C`gVuLWah9Lug-sA$Y$%y4sk+*)Bw*Cge#?KXk;}U zs3f+vf}{QQun60hK%xo3S#46noL4jitOLT;Ju*D~p^Ca@<_g%wE+^AN1fZE@VQlRH z8H{%gz$lC#HnQ#G#{|_pV{iE`-vqJCN77|lw)rth<8Mbk`>vD+}lRt$q$DO1`oX;fpnB`j>v_3KqHUu*h6av< zs&>kAwRN~hZGf7ZhMWkuJsF&EcTPuu>GIxp&eJm16nw88xG0}1@bwSl`r0uisQ*O5 zw00m4GkKR6wm{=x0~Ryj1J)Q&KX;Xk#}YNbeLU?F%b$C%gmuW}zOz!-_E%ERoTr9YK9Kh#I(mD(NZ&oj#^7Fd0YGH3Z{#Zq$!k@d$lUF3hn$fcf)YjXa4 zNI@EGr=hUq=_p#?3Jn3$B6e`*k(q1oJJmlZh06*->Hmh*3g1R?+uzEvs8xyhAzGLlT{ZTQ&URs#~L-00F}3w+OG^4rpI28TC(dqo19AP~(CI;Tb;Z85y0Q_naLA zrC`~tK|JGX!#EMN;y4u{5)w84z-{0QQ zPA+PnUmE|BoiZ10>ZhCk_dJJq;QVdV0zf1g!wUBS?4O>%pJ8MXU2#HagxYzGivpM?cjMcb^a01S!z~R` zeOE_@YTXDAEMoJ+#e7!p>AquCr=7S{rttlEpvePN=_NDi4zJ``(+CH5D6j!$s9*S{ zJRTVth|HWSX|vbmoUOuvZEx}W{hGW{cKWV}4ijB?j43+B*ICbsL?>-JifhfRide!2 z4R|q$vO(v3kw3X$>J`f%k{wzD7_VnyZ~TOaSg?Ja@-Pg_YT$~BCFzcPoiHjM(_;my zl^sRON&gu*Wv}^92RI~RYkHp`j&FQ#_Z!yFn0QR|z<9_wfu_cr!DNBnpHc4d?IcqK1L;}i*jh3HAp^86^ns84_?H{}RX2x3s!o#yeCYotk z8&_0p4Q@u|vtlx^9jYAI$N`vfotDq2Dqu!atn#k*h#WqxOe1`&c?_YiG!&!)Z<@(| zbJHD4=5@w8Y3GpusvB5ulyorl)fM6yG5mJ!C{I{i@b3t65v->@-5?=_u6`kIH*pwO{`0cxr2(ZIR zWGfQ>BNG9dNKGS06~}<=opeUfb&y_nTjG_7Ie&y91TGfRk_bR?To}y<+D!=_w!5A0D0iq=H_6h>4p;uE zIm=fY|8U1ZR$`dmy?0DD<~M(c6Gtl%Xap*VuN{a0Tqc_vr();dwqMrdx}+J0oBxRy z_KWM`IbQqJC05$~NhG(7(@_vKG1PXAJJB}LCjp>2%`L5fdJN5Z^+QMqvEp}hGe+>N45V^dw6V~0;$)K={>LBArB!L=w zLcW71{RJW`0|dpk3kf5={{2`Ee?J}5tHSq3l&6go(`bjA&x2Q}dc5drm7neVx&N35 zq8QY|N(q5WlRzQsiiM3nBqZo*X@1|7mIY zYv$w69NyOYx%En+5M}QEH}>8tD6TDv7VU0a5(q8{65I&{cS&#$E{zA*;MN2P?hZkN z1h+JWU*b@Z{|o)bvpx7evj&^{U+gmX+F{vhWLWQ4siOxkf9cmE_ul#>XeK{m#g zU*P#UxO^Aleo`~Owd|X7 zfA?LU0M+&oiiu9fw~M#;4U~-CBz@DY1_d2Ig8VMT0_>{-an?hz&%*oSEL7CBWZe8k z*({m^kw~3|fe$IGD+B{Df=s#-Zs_cRcr*k6b zmOU;tN^$t?us}~5@$)^WqPSiPgR}h!c9s<5oWlP2pI(ED^rG-SJ))@`{QNISYP!c9 zBK{3R`rf3?FivFg(+$a&Mde%rE{{;DsX|C>IQ4PQhN=Q=kptUlkJ!Be**IH&73n7l za*CVaNiag)aH;b((OmM9(SK!ZG>}zx5oOXKf1Gc=UpdK6zie0EIeI6%`_h1LvyR=M zbJKR*qJok*_;yn)fJ+@5GZ?ytM!W8Tf!(_vcy54y2o>`g>U(ad3tk;F?{%99(#jk) z8ws|0n@b3DkcSSJ#$2g}y}o4s#8%lgBDe$J1_&3^FRTn+uwi)J>R=PEv(@Tc$KB}y zV@q$2r~I%%o8LRi-;kVuZxI|eody^LU!TB~wfRc56(B3MYsX_^?(HisFSmTxa(H4V zRK%1JW5tj<A>i*xmW)2ofEJhR@R0+fHBf9+glV6C zGcStkfj1dPugj?^A+!&Uz3~@G7yWr1AjM8x7SIUX z`pB&H7nwsd{gWOPvYhmYU!fIL(5~rdE!|f`_1v2I#^!0mp>y}2N`c+iuEI}yGqtcC zOj81$ru_-i8%1$79v!vHVNDk`iF!uq^ZpF_uHym5E++zDrB$pX-uV zVt$`-&dp@N@X|P+i~c>Ajf$)c8nJKV3%iOfeVPQq#zgE+2zJ@!Tb9~?Uw%1b++K<@ zvNtHwP$k%O^rR9Y+1<-M6kSa%4Bl+FgchG4#Wa(mkeZDD9`)IOmZ7-BJrYlifJ^MY z{#y4UM#+VMM(!zk=Y@;{+qr;I@)aXLIBmXpfAc{@vZdKsQCMI8eaM-jQwD}K*1dsB z5#+a)nu7KuBk-kx53?zrOXa%kpwv=wxcFP*Kd;m28jl_;gB0nS|9SY-O%KIs#@Oj; zI#HC&3UxfpP~a;Q&R2#c6$w+anGf9f>}IlMb*+)1ov`bwam5$DVUnQjM$DBBFlQI^ zcFP1s5(KBZ>7!1QYj^sp&w)8}hHBBx9r5iJ96>MY{PkpsZAWu|J;AfJISZk( zaIEj%FS~F&Q>+J3h@?+C<~M;Id^k-r!2E*qOH7#5=j3%SINJF1c1O7(d48wHEnu(CbHgm2(8!iL#%#)+KS#gJGoe zi&JVL{_YwqaESQ&wk4%867%_c7#3J$EL(GNioC3Pf8(@l8dH46j*8$-CXexPYd%o& zBq5kPrKJ>4QjGlDz!T7$AC1!6%x%wpywP}1rD}#eL^5qAP>@;0>a=yG$%kE!gjM&P zq}+j0ZaCoy$?m20RzKxk64a$4rM+Yg6IROq^KqDQ+(F#(6;-al4o)(Wjkebj(y)9L z$kRh5z|T^<ZC&=Shw$KL5glcT^KB&<5{eWP)_JQCQ=ZpgC84~r?D zEeiF?%KFECSC;!JvnVjm`9Iq;%sR>~1(y3R^WW?5!$UhlB3&E2lK-`%Lj2n_LLI7u zqW-m$`~#a3)6*Lhg8#L%Jl(%ke5tO@^Z(EGa4V@uPAV$$$-@1o!Yo*9Y|LA`cL6C` zz$-<~&Wa6ows)3q7Ka91M^tFwR91JyJngPZj zkdCHcW?)#(aF(~arl!t=Et#Jh6B=b{ukxQQts`up>S*DotM%`7c^%s?{=p6vR_g!S z$~szgDlnU3SLJ_gwKuwzQE~AVALReFwbW(G3d)K~(qI2;Ybs0Reu|FD&U&qh3sh*V zsi~pv=l4cSTSrIJ7zRSo(o|7URC})p(gIxRpAN)>na|-tmP| z*EleI>e<(uF_>k>3h5xVGbV$(0&39Y&2Lm1fyIdg#MeVBa~jfxG1)ze+yR?k{BL%D zFyf=llVLVkwLBlB)~Q)%Z@wi#BAYbyO)gmyQngKy~GJeCR`Ml-V==1W4RG>hAbVZ_VU3=Ee`;+o6^Q)69#Inx* zhTNne5tsm0)8}!s0GAOx5}JioO|mJ@dtj$-1CCySwK)qeq#zD4zQqqX+n2x-1#X62 zJ0arr#VXl+Ne>(`8cD+*gVB*?#aWQ^v3F%i(W{SnU%dvSm+$kk-ZoL3Bx7F@qtJ`4 zN`fGVPdp8AX!S_bL}R#cwKnoW$UmU78F#*S&TQNEiu-6kOv2Cur7`T-@prW+wq_1U z5ANy2!3T}8C!GO78Ka_=6}R)kMEGM5GtiDY7mc_Ay*%u>lhdHwV^0YXN@t`4zkZlq z26T9z`pv!_yKfK(`72Fa2ubNy5G&5bj0Yo#;MzIuL{`NL?MDB#SV- z|854|DUf8gRsx~q@_aobG}pL|oZYCtn6E29MHlaMgv>^Dny-tdp!V!D5Wt>X1FtyS z_8rMG67~&^J0$i42>CU2bsqX>m5OEmm0309l{~=)Bnu~quFcY=KF-PlAHO$nW&pb9 zYjKW8!k>%EykEmaFh1;&5x@e&2LhvL1D)fmy2RJ140jeO> z)wcMYfcn_2yFl163A#8HKP2-G+>j_`oK~6n_&DuLPX5f*zR}RzfWeiVGavCCYEYmJ zGj%MquPx!yA8%RD<3vRAe296=>KUOXH50>uD%Zh+2^nB=wUO^ zqk=(HLFyZ26gTJbBMpyVRE9jXL@Z9FX(=&+9$ja4bx`v=0A54&gBqL! z`@^XQBC9q#a0Er`Ke*h9G>9&jREGeTu|RTT3;FHxfB;Ci;uxQCNKtZ8xl!-Qsv8M_ zHKMZwG4P2}5E*4M3AMfB?hQi4S1}J3Nzko=f-f zWF>$+&tHMcnG7faCWE}jr|;h)g4eI0mInU3nKIm!Ln}rY_0K4GYC{sYq>CTa-* z$@SR@Kngf<^UIJA-yX2xEA_#Zd8vc>KJ4hR6qxbX^Oh^NKaRJOIi_Ygz_-2M1Kq>L z42*shtQBB(dv;qD`utecWfe*FwfEsPbn_eborN7gD$qD8DoAZ$+}wo{j_kIH^eZr< zlaY`4Xi#KGbDmRL)jumCE+hC-DRucwynA2CE(PRbWf9rm?Mpo^hVsu!`J4u(u`>HC z0#e-{N#cqFar?5DfJErjd%puy(P2CjG5cx=R2XzA`FE zG3;1T3fiFJwZNaS-TK_`)c>oe@#TM$t%EfccFz2E(DzW+mG@9^S2!`kj5 zW~$8PMnHBBCp0ku1P>Q*Wk14}IAw*9DLi?3u&qZxtdmG3ZZ8oJUIzkW1xoh@uMvQr zop~U=xK9Z{{%0=6Q3VcBtHwSm;Smu)5g)eHxi4v3|3VCOypsz8AW@1T-uz{OFKC)# z$#q_mDg3$^s3;IGt?i^JgHp^*?=&MccMF0YST%#FmUM%jCza!$#^wjU}^Ue;zCZr-y3=MeqGe(`f^ zd%>0i(d@mg5YlPt+~BbNo9QKAI4NxCI6%~gIplF+H11|ZC$5%{b}iA4S1&xZJ3x>%#>qe+}e6vt}7E~*Oq1I7vt|0xac2XHFJE^Q+!z)yfbrkuHG5K zh3}!_@@fbAOsa=-814Iq=B@2Il22+q}-^4+{ zG7v8JUAI==zQKGWh|avn4qN2qD#!a>(%{G0Agz`F!$!YzcKGqg`ETMC=!_?m+AejV z^C;k0Y)0*x=!C-H@-xmB)dY;TyTZ3$@D9k=u+ZzULX*ACu)}$2Ic-pkYjkc#svWUZ zu(E*KH$@r^?$VV7Y5l<)0Vm9EF(Xv6fv{-tT{GD)3Lx{ly0-Bxie4L>vi+)-GfGPc z)N9>mGDL_&wW|H9dG3C{eaxChORQSI!hhvRd8eSZ5&e5BHts$)OmR`VZ0Y+cPPx5p z_1rw()3dulcE8js_~25Z`?kbw8{l>>>x6*USX?!JWML7z_!TRt4T|x*u3qw3QObI` zX>$V^$aPh$kBu)}V@w6-Yl4XIyH5g;?>%egzHNWcpK3pT&mDB*)&Vye111VWGkQBT2vVEt^Sr;l=h)jdT;i=AOYh2Vbico7iicerKd3xnZ?xsg(hkixoGQH|yg(Lc5g&O0YR+Vu9(W}2nS z(cx`A^!|$I_tQf-;59<&xG^0m7CnJ)(73d?&{+SVcDoC$db8hnx9GsxXOJHBqdf_9>A5(C}OYgr8LzK z^*b)swR|N<_FWXBS%4?l5clHMBEEj)Ck+s<>wQ)Cw3=T7T3MUp*TtzEs5T$Pk!6HY z_PBdRn~+>@i5zNxn!o>5T@D{nryV`5Tu>45zLfzEUyp=w9+53q{r7r4@pqW8j8TWM z-^n}mgsV&(L9J_1Z!6mL>kX&1Ea90=p-2>}sAV!=SJmocnpSsin~X;As2B zVlqCNy9(LyTr9E6Onx-pB9gIaabo;6N2*)q>HK*Ig+f`r zIw(o7YbD2rTSd4n`?{z1VeSb>Ux+E3bSB=<sO8cefs?&*pbX$$wCcQ!;vc zc)R_}FP6RRtG7Q5v~w8%<#Ws%NO{t8kr&XgcrVVB>Fz?1;w0Ix`?p3)mw6E-#_-j8)2=R#m$sBgT~2c2VK-;2&K9V_w$mK9pr6RR-RH8MdSAFCuH^j$iE3 zS1!`lkZOo#KcfJrs&;nVZynK!>uPn~A85-b!7MOe5f(lyLYENtrAq+8K0^q*ntt}A zSrNd7(P8LDHJll+DT>!(-He4pm~NWV=@&8Xk8hG5;|m`QGmvQu3Are?9Wn%b8oA%p z-2Pa`VGa2Gl?qjnEX;}k^c+-w_ifgFc7)a4YbSl1Mh7wF%P+(pIP__m`diKrqz}%% z9(XNQWp25>Bz9FkxL!CD2JOtmxz(xgH*UDwKN`2?CqWAId=6e!1O2IklNeR7&vTB6 zf-32*)ousHT-!eGKp2c$*9?DeOWEyZI!@2?sNO(=L)Wl${z$2IOVv8aHzSVS&ctsZ zrLrO34*qJN6NuMiWWcJ4JnkUj79VXJJJZ!#OK*3C?{P9L+?80xlXTO9ll%5s!=A7s zo}9!2^Co6qEai5iwti14Ga9F#OSb?q!`AZI$>^gh9Tdh0w!eWa`R@_(_?*TmQ~MJ7 zgHd|A0sLsyBd&XpSpw?(}a zbQx$%;DBy>ZIJBDoTNM9^q}9u<{7f|XdS_p_kivUS&2580bXw*#Yntf^ogZ=fqbD6O zR%j>jp5skpbb&T3%KJkDceut#bsGWE+F?d4wSN*UERR%88 zfcz7R5kM(o=r)S-qdQ|uhgKav*Rsl{v3#A8Y7|=#&pTi+^-UUMQ?ONyzu$<^ zo);f4-GZBXw&vLP8MAC4a4wuGs3%u+gtaypPKksigHk|#nF#i<9_vU=~R67 zt&pKbvPV&D`lGt^xHT**G6zQA1FX$sw!A$H;OUen@|(Vyl(qC})pMDuw*D&q_o#JG zCLHe*$+ITbc>`R|J>>O!*mchofrVYU0-1yk9vE1v%SGj3STM40Ra^pXKRMcx=AriP^B6ANxf;5Abcsk*>S?`EjxrSUD8Dj4 zaAV@)Jb!^?oXy7lWcBHV08j}4ml@yS_Zh>%BiD;82Uwq#V#zb`#KBhb?BmzX2HcBf z_@8B6NabB2US*5e=CyZrroV=3{-~^su%j=Wr>ne`3z~DF2cn|l6H!P42MiL}j*$yG zQSw~7(soCDrjyXNEwX3J*ONr^t^p8@sJZ6BmvWVM4DGbLi(_@!^nXV|JF~@F>ojBD z|3;gE<2k+Iz-Y}Wkl_9K6gc7wQ=Eb1w!%E>7PKbQF#PUw$IC_9vf8<0jS~mBvnDwt zp~0jtVV&UuC!kOo!zwX!W$hP@Hzk-CpR9IY-IoYc9MUE_H4hn$&3R{2VjkoQue2ll zkfb&;jXLjK?oOWmD|S%5kE?eouNLKxK8HVed2A#wL7+Mk7?1xjv0v={DDLKeEvYnL zAIF+B=p}DSV)y5q*AZpsI&*%c7~U5A8Zm|A^m_L(^t(jtzq)})tHt|*?UQ!QmPN}g z=L8OXvqa){v)j+T`H%8I!4ulibc+vg{5wqn#jrem0Pp?aNqMBO=8Q31do29kfGBi7 zE){ACHu#2molAdDkv*vZ$w&T@{>(?8;UuHYo6+_0({ByMIJ}br>;Na_e2H^(5`G3I zX_g&1;a&QngOEDcX&pT_$+S&JiSq>~h&EP0VYH8$S7Oe~n&s87+$2XAaY;My|aDy^b>~r%1=h70<T3lpm?UpN z{)m~SLeZ11Oz!RrM8h(B%j8xX?V|*jJ$S01;pi(|M!;Da@qYQF)Orl@Xf%T$?8vl% z2{^B6-)6x5>P(UupY`O`k50}w`t7{c4Wo(yz7L4;s~-lzHOiSvY24Gx!?7)blauy6 z;$rMup^7@Dw&BuuNnA?nohp4)(9{;eG-M#3XkHL zT&u|OL+xA(J-u}h6Rwpcz@A~iLHoK*o}rJa1@~y16yGlSPDCru6gjD-Pq9at<8F;( zCT%QSjR{SeK?Bg~IuE-OKH|SVtX+KW2bXb8HcjH5hf`CeW+DG?#l<(8XroYd{^22- z#ip<{{%i~9^RHfit;S3-K0grAz+XrX`8ug{xi~4yh8q?HOtYuAjb8{SG|vq{_S1@~ zm|QKHaeN}k!LvMLjLr@o-#w<}dS5Od!aowhA4shfW2*DFc`(659qjI# zm1|SD$M8G5-rCCfiMM|IN@sGZ&1MY1So`4cSuN{0Pf+V49&zeB9@=xDl;u)#2;5cXs}ul}$&7L|Pm;)=_%rHCuHIKuCh#~=9dMCComC&Gd}_%;kX?MIsW{$ zHn2@kg|JdMew!gs+0#FQ<=Zl)wCu zLD_mD4wn5q+F)2e;e{&hve3YGB*mKYj<^nkz1A4sqPm))iXN7~t@jhBGnrs!9auNk ztflXo92kv>`P<7~=a#)@iuJTZrk2R42|bl_(|#Ro+;54^BzQXCBxUTbFtibU`Ofjj zUTza5ZTiBQQ|z_cU)xQ3RSY#w7deMUF-*(&8kNMbo+Rw;JW$P_h~ zspZYS6fg)IJIXj@wj$KhFL_)b2_FZ7G04gc6A)=&k$FrZRa935l6au_Gr&RItmxd? zny&G84mvZ*^QV^RjxNi3Tgqda@c>3PPMl#Qv(e@7F^}s_9w)GDZm4O0 z1m4c-uC{I2_wYV=+7HH=ou&~eq{n8oxB8l5W+}yz*cM@LL2`9>S)*ZNu^D>QY796L z^1RzHGKaoUd8u5v_UEuI4r}m73(TN`)K&G(1B3`Mra_P*m%z5f6uESsQC*Tg;S$ z21s2okcAaMY5?zY*}D4niCL0JLFdXnHk3yUwo@=A$?UcS#g$ZSXONIGkU5${<~#k4sMONiZ#c%sT>bV<=ROarP*-Dh3LUz zR;Saw_oBtBd@OnaE=FAQRJ-4K}FBfd@D9xoo-u6QD?GKN0ZJ?6ITF2dXJov zTJ>QwCqpIaZMTbS7C5fDMiooz`a;VmbzeD)HN{Yrub^37^Mm9zO^*31@IeV5YC@p{ z(4X~R=#?L z+Vd0bK5wamB%5YGV(^3Hi!aH%UuafD@xJ&x_VAH5F7Nuvh)++R#ZNMD)sm!Bapr5_ zlL?>;4b|l{_6`-A>bu0UyYv~8c&c8{CsdGKzKoZfq_M|u}3GLF2Qo@+5S1YaozW2JzQg^j>6^m~-m@@53JpJccjl3u|6a#Jb2NZZX zB>OrP%=%*>oJ5!LIV>-h>$GEISzmd?^1awq|D-l2-Ri zX}v5lLQ)4KFi{zBvX7kI9`QOFd5oad8@fCt3*D81x9m4-a5+zrw?xaXRd!ya8kj-) zWAckPm)Z?nvM(Q81h*;x6FCDH2=XP_-uw7g)9>gYR)3z%5$A-%gq2|89>~CaE-1it zm*E=DPz8}f$gpLltvWcn2Eq`|_c^O- znJ@?^^W7nZQSuuL;gZrE);na2$I6lT#ifo>o2({IPuwhDX_#X|@vh9rI5Lgy{w${Q z-&-h9lrRPpq6@5x0~@Ab^{CXJtV}tHVL#24nXs8pIUAYZs*t`c8@WE%=cgPI{BMt- z7VO8((A%G|cVcW{ERhK>n|aR%ENkbJQkI@=s8UNv#;BY712ThK)?%nNYO5p4Y%|)l=BdW~&xY zXdHUi3pldt<2zjHdAj+ej^v8!A`B&!0sl(H*7%HtLKR|E|7FZlNspcKwz|)j#ZNb& zek0oLBPV|wW#{%;;e|%SoQ67jYCE=pF%Z=mzrw%ADB~%~O8h=M)L>e7BR%rQm)I^Q z?kg2Au;fU(Bi@Mi;#BWGtg;^iBUtRJ9qzL&lgm5yO@@(-5}Nmx20Oo+Jpm;CKHs3A zZ@H{;Q=q5oea=sei^})-oH~0hWQrnx-DjO}UyXRLRU9#S-*!H5ZvOcn<~)F7BB&kv zM&18%b#S{m;706ij^SNugyiPe_qGE-nMtm4tDo6A+D9k0g1Zdu{P_GH2XWgXbo=Jx zqwPOOBW?H8m`%HfcuSVMbLT=!(A-JaI9g7IVE&7a_Si?22<`~j&Zz+!uxt8HI(~3t zcZ??3RfM*<_TuZxk=AwpH}d%tOQhx)+tjcG)hzkcHuOrZ>ss$C;r%+vpKn`A=CeSU zFrl)|$`cW5dC~(!i-$c1aO|@s2PIpJ@PL_A2!IHPS%IwY^!4NLsU;ptU9yX=JW2sg zp%0Rqv?Rn{0Pb~WRvyUUa`WuON)OV-N77E?7=18y+a3Gqx#QEzGu@AnDQdnF147A$xkl#K zz>IrwW)saTC#$Ijj+6L4V~PdF_cA;49??hCmn^bk??sMY%(+Fc)mFd*_G0(7#o)o; z7k$nRqq7OWD)B=;{80GS7Y4yqhMqpR)>S9WDV``UJ8Y|E(nDOr-G4uV_Sz7~UJ*n( zxw28SuQ%04?=(~6E>~Ie86=d@VWIf$C9@$2BPZ&oTh~{?I6ozcUm;(19rxF~aD*3c zQs=~)07MY|(@F#j&1sUrw+8~*zU=JEA75(|9*0hh4MZKr%{Dp`7In#`!yX@!dpe3u z;EwdAvr)FA3^k-+YPFE=YJ}PDPuOrEhCZNKG<9k%c^+G(yz6xrUHi>-tX5o5E2s60-oWPr5Z{p! z$@$kJ(5QlPs<=V*h2a)Pqn1kVXpR>w^U2!w1C#9HVmXtlZ7Te%fLJBKSaZZl>~?Fk zP8Pd7&odi7Ha{Y|JY?pd2@76V2^D?OK|YA=+c%!Ip7b{e=!o?O*^7C zOWzWN{Ea-|h>tW{M`Ue~Yc#nq`}8nbmIs@CnV73C!8G1u63!YK+a!w+ zs8&&;>iK?|YmrX3Au-6#Gw=2}YEfCovB)vYp=cQPwF7s7(OH4ERU@ln78eVB^?GE^ z*J+1D^K9v*Si2jR@hm$1pJ8KlPfBYup1@{qN?qmHMPhC3fmvFN;N!s@^Ka4Pe`ck^VKWZc!ft$on^xQr$J{x9Mf|5z-k~0iwY^AN-0E}T^z+el9Sr5J`C<+IxSL0Q=Fxu z5)~G;Pk7aF@qiLJ?IcPGykd_5YM7ne%GE%TNkYEV#OaSn!e?y#_*;-EJJ3DKSQn26HlJqr6OC&aJ^BKrGxSOe4>&oq&uN z9-`|#=*D#aOTQP_If}#3i~i+zm1ccduZ;A=z4#h^Gj{}o*7u{=>(=Y6l#gm|xIDhKGmt{If3x}uOXXQu z=~H4lWY&;cR%~_@)P#tp^6z`tHkp@9sHB&*lW@gYfold?)Np+;qv*+6=2>>2gRpIVeq>nCI#FLLOFo{(el| zeWBB*ni+N#XUwe<5w0`<>O4CCvy~9f@#VP}raDK-hpz4) z`}BHJ`C8`Usf8@ueQ%j6gzVOs+B|v$w4viB(QgQm0uTI8&2sll$nCZ~{fvZ$%>4l* zeL0M?lY;+*0{3lo?O83u`vKI67nUF(7fH{Ow+Ltc8sIR-gMmWT%#Ns+=w1_FoL08S z{j4HhZeEj;`lv|E|BT_31?m#2R{x=TE;USj=gX*ohz(D0q|gI>!!%OOlGu9Joo13V z^LSVk*<#WnPL}$O{Ku}L+@uX$Q<9H09}AyZVlI?>Y|L~XBg(ICzC-WeS5^0jj*`&! zD_HF-thDRly5rFoqeewmX@b6Y3d%S+K5vC*0jk`L@_V9`5{*2`!k|$b^tIfJAZ!1& zd7TMAbk>OPXpWa@SL640x)#|q1M%q}_#$3Yj-Hi_0Px_8ho&q104Q=Y7gRk1l~Zio zAkjXHL=y|`Q-4tXwW**HAu8YZZ1f%%q9RL9o)p)m42r}{j_G?!_y*4YQ;lHd9D6+R zERUtqtEKx}zvAY%GBE;%y|1hv2#K-|7Jb{nDzOQQ?<^F5Fsl1mqLU;TPdWK(Jm>kl zqAlRs?FCPA2rQJpl364&)cMlsiw+)ztobs25RUk|UJ8=MeV*FaCc{&_5CX zPOQBl7HfAOe~tz*`2aD>{WIFpTn90f0K-zMG2SijE2DQiooU0k5cgK1`3 zxg8VOV1xrXiEP_cUHLpa%V@>&v6;Rm2N$svap70oZz~xv&)v}+RTdT&8{SNwCXs$0 zV7J}BViEhy!WJPah1V2a)x#yQk-fLur06hm{t%;z)pt$V4OT#TJFmw1=P6L>hX#oS zuH&Rq1omz}jh?BwCAke4d#$KYM%3D~M;{3kheqBQOo#vr%U(LF;qQTNp*OO#_tHlC zq|dQO&UV{l6f$YQN(_i~oI_7fNwyyai`UbvQS+Hd4R8IL7Ob@Smn03_kN?q}4_R>~ z8yisVOfhki@^ICS(C2Qm#oNSY(@-4cj?hrFXa0cNGs!zQE(X+gM~ZfjJRVlR^A%tK z64uk~-nG*XTF-pt+)MEftB~c-vpw`;8ihYHm|+9%cJM-l&Yvx|$Y*U6%?0-Tq2aXr zRMwi&onMKWxFfm8T$WYc!j7I}7>d!7`03IY^)1xmo@l-nHxG8!{%7cc0UjiE4xx9F zmkc|xl%OW;=Ue^)FduX?H|_Zh{-U}ua=D3BhU3@M(Pvhf&tBZ$;EDktr8#IxU5}BT z2_&ReBkTi04PI$S9;BQ0se3~NNvm%4vY_&_=Oz9f8x`){G#W$^+S(C*+UJYabK&6(3WFHsQ>J0QTiNKw+o2%)cEEFPth|(HwKaoc^V< zQeEm`@s-8OeW7v^zD>VMV5Y>N&cqJKjk@1eKwDKqQTj$7(okJX^_&kr|F4L`DuYx> z<*n$pk*2GJVC%6HVX#;L;u!D9OBEuuk47~TvI?boWl36K9@I*)*{v7O<35XBbUEbO zlfkaPKhn8&#}MSA0c^Hs3{VySSi*^KROh=lqBs8Cl^bX@Y*@Z!{YIP!jdR;L>3e7mOevpgrZ^mB!5pywzoV`y z+W|4u!o#siYwg{#=^~1Dd~iRF8-^0jWCOIxiXDY-9d2C7h4%gWdG#EducQDgkrOMX zxFH(<7EL%vu4xD=6z_6u?Gp&hMld=w83+lREl|Z9gRMl;wmj;V*btnyz0?&Rkqh0D zBp=ml*YG}RLU(sc_g7U`^(&kK0JW4y${$3(fhV%o2;tr*NK~`}^(vys#U=pGmrA(j!bGOjBtF0Jk zD3;tVP0cMAzDd%P#1N->3-XPK_c{EHmEVwmyE3!ZbKe`5L*@M-3HZ;8o{to|$)`-1 zdFbeh?0ku}UBrmGR@i4}(0(l2Nrd@RD>#3O{j}e`ovTmbE|7OLc@}ngOl}hFB~Ul) zu{S~)zTt|`!duUC0QhgwM)#8=!DG7j9ZE|<=ow1*f)9`imM&E-KF9cM2LER738~TT zP(m-F;O2ND1OOS2$$*Fnu-SuNx9pv)S2Q~qQ4Sw`)4EGc#S}EJhp-s6@>Vow|G+M9 z_~QO2K5B6izn41iQ+-1}YT8fqC=dWbVCTkIV0{O_CZ|)RRL7>JP^5YhY}26|OHV%M zy9j%X+<2NboPdHi1%r}*7C&EQj&7;BJ=<7i;Ee7F|5A+s*3HBzdDG9Lf8C>}-T>PX z?0j`ivH7mEl5YBBt`f1)%nigaBh@P!F)%<$@=RT{_z7F$Odq>XXRjWaerG($dKGj& zk<)!H`qB&MxfhDnYb*#W_zNs`u7a8#B3}_}wT}u`!=_c_>bQR|2TG<>jlh1B759S+ zn%?L1=c<(6=7z zPL+6SK-VC(xEVA1gaGE`-61BY2D&mMe>YrT-OL*Oc?4y}tcFeLdn=l6qH1Sa9Zkl$ zx~1Pxt`({xskj9d4^q)C{^2@@-_iot92CEUW8az{s_gD;jG&1KQSBUSo-afD1oE7E zc85+Q=P$_u1h;wN3wleF-&V{IlE6#Y`EIB~?QcIc*U#y9ur^^Tx4nCl1td>RG`@=Q z;+`ExOlRZh_Vw%QscXOtc?=p6OvD zTh^vV(}V#b1l}g|ijxgXV0tfRr_SruEv5mm>5>q;!S+e)mF@t-7}pv3LFx zrK8QGtWVZ(0KEh&eqR%>E#k7-?`qc-2!{B(*|9LicSRQR{Zd^yYm z&=Tin7_G;Z)lQDmA4MW=0X@rlIcO;6Da+ExIWoZC(Ei-2RH~=LOt;2aCB!*K{<)TX z7E$PNti;#MEAn|~$XHFrD&3v9JC8))2s^nV~ezbln__ND0@8!TEV4RB(GOO>bhuom|^h`D-yvFN&WKpqJAwYaxl z+K2H|5^Ny`f<_K04&?;6MS7-d5^qr)^pq})Z_47FG&w+dGfgiBqQXD!kK63m0=G$~ zx~Xgbn_ev&=`mb~=eSM@`IKbJQ&&fcYuI*_8{r2UkKD%c-I=+q?BG}o(pU_@n%F;d z;9=^(!?m#O3m#1&7flrlWO4=Cry{T`FL~34^*o#&x!~$d$7nFmHv3g}^+=9Bv$;83 zjpP`}r;&Vivs0bsBb+$Xh}2X=1hxd({F2`>1;8$c2FcEcgaG(p_P$)rd!^lu;FH2P z&AWq>Ki8M>&ye~iWRJNq#WQaA7PcSs&~df*;jQ)0gDlar$LV+3Da9O%N{=z#*&!4W zp5(S|gX_qYjWZY+$%Vr5a}O!o-hE+3>OWE0u|Fs|5B7|qLl?8kNn*? zQ%)uS0~*a`P8R>j`;S6UM?!ja$$3Cf@2R%}xy5JguFne!0JIsoM=ndoOPkx?8^awf zxF(;ZcIgYRtqcC{=cr9TJ?b`Pl)Day!NQ^m8CCFl{rc|gVgCf$-Zk^Etubx~!^5;X zenL_=rfD?HC7U^9Q^{otZ%NqR6HGjYqDIv!b6nYPdTd{R%?q>uQs~zM;!Pv@`&yYyWP{k8cWp8R(B}h0d>r$Ae z6_W$r2gkaRN^13##hQ5y&MF3GT03}U*A%TEE8P#uD2JjAoYxob3jEQL)!5~_rr{P< zkGip+z3>cJWF(dZ`|WoBGM5B+D7QG3jLMXb=Z5x^O@8B`1GX!^p`7n)fAlr>|SQct9n7)ecc5o0pg&Jwd+;w{l&oJJ_SisN0D!wQY z7_MsqFZRP#*BH6kEf8$%7zF!Rw=F~L$)|q0EzL#)EIz2cHxGoIu_UH_8hkskHE%fLv!~scgKLm(QN41F`jTuK#2@9 z#Q@(Jo-FK=K=R&gUCM|6VoZ5A**qQn`?L)tb=7;x%UV;wA ztd-udkSX2V!Kj2Y%bGQ6GSL&!d#Y>bRyu7E$ifR-$Gzy~Zi@bXpq}4H%d^cd z{OaaifscwmP8C^8Mkrs%$7fZ+1jgGwrMeS!M;E{073kOwP)}_0%U?WIcFGm3BI#%y z0)=udOg-YLOFIJVwD0$#;4!ME^>&5A3!&N|?_^PbtSmX~j(2(Ir;smPr<&>Y2stJ~ z>IV^$+kS(Nk_yeu77CCuDNbb7tVBEZn^lIr{5q`>_k%M)St2bR7TIaBcM~MOl;~~c zd;mMkv}{Jw!y}E|QrVxclX-3Q#58u-- znoY&<5TJc6D0h~a*@q=Q;bZ!BusOfqFVb`yhr>tuz3>ehGrFTmBW|^YsSah2mQRfD zU0#R0%sqo=<9eT12S#yncCPJqohX&njq&rO(H}*ffx%;GaoeM_UARdM~m3UOn^j-0t*7dP@G|7@!cTYNmd=~>(V~p z%Xp7=Z}Oka$ql19kPPcJ;E2a7`%$%OI%p%GpyumK8eDC#TF`n?m+1nn|JsxPCH8!ydFoi`r*j)OZjAFY`)fZ(}Qmm`|0R@C#c6=2_O#6PtXx zWC4yccvBaftSl8Je?^xq=49+*HXe=U2iM=mcvG~8g<^Mj{@H(#_T1z0$7oig%mIdW zyH(BOhQ{j|V#*w5Nr%#y1;>z^>oPZt-kO^3;E*}6RCfqd6W_W+FFH%hJ-l{Fw=w+I zfKONF48P5I%E?mPa7I$`@^>o4V&;Vcu%-)}xovAs>531R_rYRrWY_RaK)|Vs~3}ppNOz^x6uZQ|erN z^k$+yw|gQSveC?^e8Jks{id(&pHscM^i)D4*R_rDZ1>*YbN5NV9Td9#HT#(?a63zn zouxlgMZ7cO6j)x^Q3NrPCLHFOp7f-OHnDtyN%K6C?VBLCSSjoTPTN| zw~q`t=p$Bjmndf#-{y~TBG0cYD{UPr^{~j^As_wpEmO0|hlPyjl zRQ*xsmX2s8#E@3S;!LlcX2(@1=$ZEO3Qq|KXD|!-?1gOkU4S6Q_ZTdI`4m21#UK#u zQ%p^ooUdQ~q|@+?FB>h$6u6Rcvo){iqH3ShoprtTsdblS>x$?x>6^FTn(V<35JvD1w@U|ja}$UWH;lhQXxmcG~|k=4>7x$5q;jl|TRfc%*28wu+)A~jZ~5bgkj{^GY;3x=elq2K^A z@}d9wjkgJr%X?d4l$v#X=}RyP`Y-N`a85S5>;4_)CW-u1CS!(;eRfIL`}3evV>T8N zJv-v$`{w6Y719NOC#4>yHxH6uA)kVAdYr3CEG5yr))1J!ur)E`w`U2!~tlm&H%>Niym$(+4*>gB>CF zU#9H5u;ViJy-;?U`o^xFwd6xB`;fjIg^OS?)u1~|Z3vo<;`HJVsinJ(=H0=OI|rX% zv(o2FxuH%6ByV{bSTQ@n7B<~&E>3wY^ee9J_cLBW$@)kfGP@Tat3^12EO4J1SQ{Hq zKje9f=QGB`@r|WFyX-Q=t90*DwPL9w2UNJ_g}=OY>657@rBb34XB8~w2MkG0JX^z| z+gIMQRsBA(XU2IWgRhx+Fc_>pv7UMhJcs?{s_SMhvy>bZEWlk-xA7bv!fZ8?UTG+* zE@Dh^Apkd14#zHoyuj>QpeHxeJBM zfYr%i!gmb4NLf)vrh}1xg3#TBrYfDqKA|anz0H|2Oe>}}>I(0SF5lg758WISJUF9C zA5+T&1aNdmrDX&}nd=aYpC<65P2h#|mg#rb`LogPUZK7hiBMh5LXUq~?>F!6ZbvVidApZzxcIijESB!D*w7!>W#~`U&#U{4 zTCIhY6O^9+^mLiadUgvp#<38*7?sJxIP3EaxJN9@A6wE`84sBg3Q5jCLm_qG1}q$% zZ_W_S6L1Cf0Q!8#rBt)WD_=5~H^#HOnPQSgeUAe_o#qWSSMs9tp)qc8?(=xNXR&_G z!$y7LhgvMo^amESLA-9j&iMFPtVQ#P+Sem8`PcYa5jRWvvz~tJQboJRH;whUy~3)31BhyJA@1A*kxTlo-==QBXOfh;I_v*)kT_2uq?mp zNA?U+^-lG+tR*k83*9XKFupEW!J|F4SP37@A9fla9iGN`CuREk#-v2(f#$hN2f-z? z?e`kaHjvCW0bDe{aDpRsTDyQe=wJ^#$G2_x#Zz;c#4;Q4U^7QP!(5~Ohv9X-&hJ~& zV=6K5KoNvg)%a44{aEee4>@n0t;dd@kOrgn%Drf+&VPM?8T6mR;1WA;sqC!%+#fIj+q>B#;|C7h?$TzoAYil#5jKrPjZylP5=^GzAPdA8xjvqjx}YIfDyhRn2AAYn^(L-HQySKySqw?y3} zDh@Rqvm!N#4`d4!xB-?9MJmUajFwGW;xL{_2G9&E?ODNLdv|ep8lyHAFp2N{a(w$jVYu$; zx?sZgV7fE%@9e1c-`MtZNU4mB(@U@Jy=e%~Qe|YLtoY7L+w4t=e%FXJhBNgB?rgYLY#zw_cU(%Ucg(g|~sTw9f+tv1HhN-~hU(*hS zRses*_{;ayuV73kUY;c)#w2lSp8VXY=rR_>F%ui~ix(TN^|;L7JUhPe`3}YCC%4jO zJF1S(<5f+z6v9i51iZybPZ)a$LIW6ak%toJN^z}jp;;ad0C{DjEeu-08pWj_oE^1E z2AI9#s>VJas9V8|v;V61!j;nL?+1$H&CXszn;4Tsg{176H5-113XqI2fd_}Qhg9@`6@ixGMcwt(aJ(Vnrp z2_$qh^F^X%dSxy}1?WiHQQ%Q)RT?Z;Au4x>caj9%2f`#N9f7rb3FeKTFCV-}W~ECK ztYShF=T?+ohY>WMG$lw(s2O)o?wDA!OAn-y)fs`Inyke;PbFIG`(S_7J0=+Q8&x?3sU>TeXNYP8J9pkw~%f!;dj zkwL^j8cD5feV-lm>T;Rb7tF2?zsqkj2)>P394D-Gi&%LeaI;9t2g_t(1f`uIC1=gh zyNPn_p-gVTY(w6#UzWa~k$Vo$=6FwApx_02P~qi}R`+&D`ZlRR9VTLkGF|sn)9W#1 z?mnN9I-F5IsAAlh@>K0u7KRo=$ozEMa_>r9_tO?R2*PYG$AT?pRvh;+me^HDoxaEjspgopJI+c_BI9A#1m^HH|W6- z@;YO9rec|JuwhmTm%;_98U~gC^6@tvMb2cOTIX&Q>_6+od1N<1isEHEwemf;w?X(e zUOJnu5B0#n=&k)1%CHzeS}>(ku^r%Bjh5;5YhzS!%J)sBnM4hj@ED|HEW%HF$y zabiAN(M*!*HpVsv`bCIq7KG-@)O%(`^*M@8I@x*(3>hI3Q zj8GAAl*WC9r7+*Nuk}=T%Yz?3%=w`YlfxM+EQoq>|D}TYIRmP-mv*a9ab$juZ5%LZ zdiu{=7&P{l$h0_NJfEGA&9OuT=QF{FoW&2XT1d3^*vRETw>g&fB`MLo(@}};Cc6XW zwcp^k4X)PjD5LO>MOXLZDV8LjnYZ&zgjnVY5Pumj2itNLZ9PiapDvA9*eWR#p?Lf# zj757l{WR_<3@q7`r>Nw;YL#mtq#AE)%1~I(x{Lh0v*0$y28GU}l`xY`psKFz$ znzcH9l!54GEr_M1jW(QK>E%7|p=2Zso;U!<5hb9y{m!a!$Mq}MZY~hqo0ltE(Y|+v z_Dw^+-L?f!ir2~-0-Q$9TQs=J#E4XzMDh<;?z%P`nYMH#HnmVGkjPY?iIIQ64j8~U<4i|_N@mL&6nr(O2rtktwu zH;>F=N+L6q+cF$Wk|{^>S4pL4qrE#VOTu$xoF3Mptkp?MWjD`5TQrLLX6FEWB#)u-#*owyBbqo>-W8-izJo7)#OVE0QkH>fo`7V7GqyP?3Y>MGW1ig$8e16Os`5$F zCAP#mc~JlqFl1R(Mil~&`Wf{0hIsbb*cUt<3jQ)$9PZ~{hJ=v9{HNCG*k zA9&v>@an-dQj8ODEK_(sebw2h6ZD-3|M}pz=gHC3OsIz%Od4P}(|z`) z%JCk-jc%qTqVXzm^0I0%g5oJS+y1McGLXEU>{T4(rxXl)lqYemm+9u}E#zsj+$2bf zyKooM-ikbRY~sqV+c?(C?pzyv)?MQ(!M(MEfhYa(!b65WI++jeMRgd=T1xCSCbK|a z{2~-#UB`jSZi#uJV0Ga0s5v;TJYY_JTR->&AiZ*F8uhi6kfFgmAm>&|16U37oq&Zx-En-Tgw68g(lJQfD_ zeD)I`J1v(SCMc#qR-N8IlojJdpRV7fL@z{uWzy}Ro+$VSoddK;7EOP2)iqubJ+haf z0$F-zQ&O4iy>EVbYqZREzd7>xc9Ed#$|=~`+}t6lsmnLi+e}M`@DiDPsAT&1(H6kQ zYN}9QoQmB_*y-yo2?6Oumi!-tX2fSE@BT#N7nRT%Y%sXa&=b8%zIErtj`d=0~}wT2R$WazSS;Q`^)nPZhEEN+r$FL@2S4 zBRhqJVE_#=mo&7Xc%HDbw^c3tA$eGto%i9)!sqcvU@9s zSI87%n)h+e;nbIb2`*b1O#bYc;0v;w=F%F8xx2rD4$b7N1122|Aiz@kjtF%sQ3BYc zw)$-oU6y0mQR!|qN*l?jy-Ya2a3W->Y-<`9a23=^@U2_w=-{=?QB*+JT!Os6mlU@- zZ9*T^QNs+Tic!rq{d_o{YM+X`KC}gH`IAR4kFy0yu z9(ECSJ1_GYr7|Ncz3Yy>LnGggF29S@aLqn8LKzvvVdb zWJ5YCX0qkd{UEzWfj4h|NvqgueXElGoURY8Z9w#`+Jgz9V0o&ao2_e+#;~q-mbz>-R9ZQs}{VHibZM|6row$T`gv zEtAgz&MgsO#qETv%@D9ZhR@=e|h(X;euY|D1T-85!jq36fSY)&0%+6kA9FD{z zxzpF7Vn2enViuI}SkV*OJOvKOFix}}9hP~M!@b#7IN*TUta_d*bP_reYc?pYf+@;J zMfMpk_*3gPigK4dj7EDL`xw4?xNBHfD+?YrsLqeWANCD|S8OrReCgtBMxbajA#f7S z4~@t;6I00&`}iRoB|me=x#pBWzL@HXeTm;srI_6CF}`UU5>+)dkdr3l8D^6;JHN@- z|0v+ChyX3FS@ln!t!Y0ir0@tsVAWs)08Vk+fs|B0k}!$R^5E83pnAj)X=LF8R{C27 zgyCQd7eoIo0yh=2hPy_iI{U_^nnZ2ij-mR`#DlBcoY}@Ei4LggCmQ^^K7v`!_g7A5 z8@r7$YJnDEkl7xpA#-a3q(wP#k@lNzR^|P~V6#fw^a`Qn-d3iMmC|d0+tjZRJ>Zav zEK~3>@5Jde!8CsP+41@&x^w8ZU4*5Be97%x_U5PO;UogYgQl%a2iqHqAL~h-@1eaW z(kOyK*27|UJimCtt+!FUw!&6403ffm5?GF$FyK3_DHR>(Dmdezl2wPr?aGep0-9_k zK2H`;8x%pi67%3xoItVP`EZrg6XW;3k+$xd2nwSEA-}XQr_fjYmt)0Ad8rniaDrN= zD7>~zebq##_hJv(TSuaXKRHTj(IceT`BMtYO9E!w#Ix&j!Vecxuf%O^t5P|KOV5O= zw7IBV4aC5qxo^JvUc|RJ!=vkiPgV$rhf@qvtbnP^CpRvMuO>Pe&TqmbFaUOFlrlmv?|7h*HBYeCmYArGtr-J< zRF)SvG2Zs&<}Z-DwPf`1qXGXVaVi)2ck0mH8mk^N1)^Bd4!K|C2WV8{n%v17xxx7U zR5U4yQ1K9^n01E$Q+XCbe}`{X*cy8K++i1|N!=sD;vYCc5}5dIYu2W-w=e}MJ8(A$ zmre&jp;~5y^dvxpWCWo=F78dO4WaHQ>x=-~@cYvdXUR1l)j%rYUnqKH@W9>$wUb0G zMX>M^Z$2iRd<}Qe_}$s0J4E2U5JK!iR_V#aSD{JicTMC9m!I;Suh{M?J)n+!-I-+4 zdG8nFG?{Id0(;~8?2L_$z6ytlV0E!FKplSWx(h9U?+YX+ma@FB>ZL^w~ zhj7lAt(D`S!_8vki$|w6JwFHD)<%Y)Ib71Z0V^0d#hL}X>;&LOk%QDQyc26n4Jg$` zn(%hlqX|s^xhbGuwq8@AM1T9C4mYfr)5TQj2;NhF{s9P}Gv6QJP1dQ~nfBN{XZY0& zJF>yx(d8x2b01CTsCX}eWD-~;UUzq#F0C)vGjG*J@H*U;-1*g!XnL6V?S;*js!~1$ z|H%hqOyyG41DR3v3OYm+Hw}PRy2!s10JM++LI092)j5*F`*_pg{{oV-PP{ zV+@Y$8a$7pBz^cNbwil15w)R?YU)-=M}~aavV(f@PIWx#1oF5={;^_T&+Wl9fQNi6 zrfdr)$`v7nbI+Lekkv4p;O{N~d9xlNSmN(a8t!+0nWY^H`&wE>?d$z1)vK1|@HQhp zMoglh@!7{JmTOEc_Q^1sjJ*YRCpOZ@V^AWls@7)!?h63@@(wyzttiny4^XxzX!@D} zv@qdQ0!%u{lPQTaLh=+=4b%7isZB!ODJg)nTy`8|+fOY_%muMj+Zq}KHN4iz9(q{f?nvoOw zjPprXYo#H0AK^UgD{d~^!SEIO-O;qsj&y0DUo=5?!v?w%@u?Q6YV{|dCcp%UJ-QN$ zKrinjHj(T1QZ&m`S287;1N{EZdyW9w*dRkk`byM;FaeqhOPm`dr zDta|%ETw7nC$lzxKs|_M{kuZI<|UvRd2Xto$-Q{geI4(p)K^L@2u@~Relhl*>vSOf zrlht8L773p>x0C}c3aEVvE;h_yIVg_tbM5I42Asnmg zSnRlJ*Yfhciyry#2; zQzm5=WNX2H4U4^x#oot`mH%^tiN)T>V((+I_p#XfSnPc)_C6MSAB(+@#oota?_;s| zvDo`q?0qcuJ{Ef)i@lG<-p69^W3l(K*!x)QeJu7q7JDCyy^qD-$71jQ+6#-t-p69^ zW3l(K*!x)QeJu7q7JDCyy^qD-$71hevG=jq`&jIKEcQMYdmoFvkHy}{V((+I_p#Xf zSnPc)_C6MSAB(+@#oota?_;s|vDo`q?0qcuJ{Ef)i@lG<-p69^W3l(K*!x)QeJu7q z7JDCyy^qD-$71hevG=jq`&jIKEcQMYdmoFvkHy}{V((+I_p#XfSnPc)_C6MSAB(+@ z#oota?_;s|vDo`q?0qcuJ{Ef)i@lEp`~E)%`~JU|+5ZlE|0)FGmt5DBlbVBIa>0+H zVZm}?5R7af_5?OS779TFaVO9~2n5L|oIvuy5EKHgA>bMVt}*`)umAG#i!Z?6AmDQ_ z6mUKWI3FYfZIBOn6ax#9pMyZlkj#&eFlHnuMIvLFz%S4;WCRn03nX=E@HA(HVN zJuM9vH#-BSFc>0dW@17jIj`_Z89K#1t?taR;0R%Yn1dk-Bp^_~d;Tmj%*)QYbM@7u zX5Qdmk3dK?6aJa0XOE&H!yC}6xF2pm_GM)V{EtR&izHB z@oG;G47??+Acmr$XJBNc`-?_<=^X7jMy6lIw5*bR)PK?F`OeYui7P6|2R?|4e>+^r z^B3(LCp~@i_*{SG>S}w+PsK z0galGNqTm5^-0gWuFz*iS(4}eqS3O`^T(&gy2O^fh^-izLDT<5qvK`}X!*KY-@W>^ zw6v?mllm{(IUYv3>=$MC!Cx&2c~IXT_xJl4S?FlvyWTZ~Jc;(rTU~AD`~4hq5ECgI z6T`#R?=6---nKV%QQRDW1o{<11~HMG=e&4D;1V}0Hw``gIYuO=aTxswWWu4RV_=K~ zUxAYSRw9f#{i9gFlq@6TQ6?-S1C(YyiA{$+$plLFBrY9#0xHN1PA5!_3HEX{zo{*% z6PgYRAo7g#^i*qAIh3ZgS4>9Pt4vFSkaQ>`7&-$8X#%}9S#cpAHWuv*E#WWZ5`%t` zpbT_6zQkx&M8t#8TJ!>LpLMhsZ&=`eG&1E^&%%@PYDT9=dduU(?`ygIMKcI*PKk>P zGIsI_GIa`zigWslX6f#4;~A6e>mM8GXyc1^)cK3%<{M;bNeRP(6h-FTqeUoEW3R zpI?~Y^R!koR}-=OP09>PKW&s8q97x!c;lAUoxAsALAPdxKtbt|hz_B)nu3gku%sye)%!ZZ5L$;sTU=P= z>J3Bt2MM_^l1;=zL1~~4>G`(ehJs2 z6g1^M>PiE6FP&!<%qYkf{9UfgB`SKWr1qY8JQQ(6V=f+f7e~cXsPL- z6!|cbS2Gas56bx@L?oh9a^&uY7X-eVoYYkLT`r)er8_t|8PnMI+$SkLPEzJ~xrnm# z^@ynZw)eA(?&rMezjyt2xulY!a`}hJf`-WtSy?a2orHduD`={UB@|`*xZjWR2`H!z zyY{DOz6b#O%WHAKBY7gtU}`vQ`A>WMF2@bYpd3x@KWetj-){YjKGmg4UjetE)KwhA5*3=Ca=?H z_L}OoXSftXZ+M#}akXDPqv^#;^+=g4@hp8c75zIJK@o?C7Z1b25_x2ksqtRn`7gd{ z-;QL4|M>Pg3O9*O@V3R!!>Nus#~=7ZbGe~n%4u!4$VtfGUHwea*jsa~Gp18jywaT3 zYV^#FRcBcIp3}E$d*6LnP`N$>-G^V<%b)r9r?6{rk|4-X5DfbZD+8<^V21(L6kshO z))M}IWC^2#Q2wq6c!%nJPl{I#)ycMUZ zuocHRS>n#--F2ip?symkH-S+=zGP(b`eu7KTK8VA5VSm;^rVYp7*0`s-hJitZ_CSr zpewpmiVw@%@(i(1v?jYU?)?o4hY?Gu$|AWy4I1v$FIg%Y+P>^;`zfEyHS2Ys_Lo*! zeCK;~86VRwOykV>vY4(3=h+Y`7w2VM%(6M99Po2q@?%o_Spp$q&KJ7a1da=Qr)Vf4q>ufAaX-F#mlL&Q+@1!n%E*Gz}$tNR>GFvJh= z!jEY#xbT~ZhMdEqErn*T)`x4;bGZ~mvi}ZME{Z|IK&HaH4Cbu+$5CUjv)Njw;eH5t zXcIQ;i0~zH(_3&BI+hxJ$%F=t`@1 zU`FgQ-{sm??*KcQ-#2^X(s}E_u0D(6znt~SKWkeIu=QnZwWh&5P)X?MVr}aO^+v9; zjKzP)P9PFj9uF~g5s^>~c(Vtd z|8C(#TzQ@>2(U=z9Z2)^?7YOpw3A=LyW`u9F>U1kOovF4z#SXhE3JKrK`w4I8uvL- zaAdDPb5R@a=A)CF!%jz`EkCE3yb3~qp8unS7g)2L%{J#?()&Nr)KRR%{B*%6T=)fu z3zZk)hWOR}T*Jo#k^8wH@@o*k#V`vL>Pp34=>p&J%<8NIOyj(IGvI5nukeyo;ELTb zgZuLwkvQ`EYF)oI9gV7jd%tY69aaw;6Gx8m=G`Izog&=vtY8!I{HI#Enl1Tnex7ln zSde}GxjIhZhUZ()YGg1sJUefT_xlD*c5p)@Z~jXvz$H(G@KryU)8U6z3&RJ$n{6C9B7{Ww`c<{j^d~Z+iPp^jSQOMYd`bqTB`)SS^P0&j8V0^!fT~UizMKZ* z%sFYXAm3nEa&-V!D)(ETHgOvo@vdATzq8r5X{1C6YsqhNvZ~2g+vgABhME_a^;JhbQ*@3|i|7 zcijDdYdLth>&8rz|CJe|5BR3OVrf(#)DbnH5fXS~1j?#Yp3!zo3&%y5Q&e(nIetq6 ziEb-`mylL)op!#!1v>j(RIWi1Fo6<6g(9V#85^w*?P~w_0R&INO&rCi5#jfn30>$Z zUq1t1AiVOyp5mg^Ocna(m(^_5#w~8AW4^StGk>a2fUlcqv|N7c*~vZq*^}TIYuhPw z&3@cS6W^f8RBLXY$mv>Q%D-HZ2%;+@iz-*2Z8|_6#2j{S?)@a-$3bSau)Gg~PstvS zFX~MmQX}TB{P#$p5`3MZDyLQHt>;@MzI7aUIOc)asAsf_3pPhPMIJs8vd6nu{xO-D zE__a=fAc~Gr#;R&R}Drhq-lU(= zvBp_*k(Wi&LWP@_dCT)2j{?Ki~f7`1b+qOOqNoShpqa)f1-$a6{n<_MK zpM@vs?%HIxay>7^`L~a|49)I2Ld{xjt_RyTr?1cDP?_POhNhBi)_#rY?Iy9M0JRKw z4c)&Fpt4k9&4O*w+OFE=r-4?ayQmAn>ws1`ycTZNg&EANNrr7nGj!w`;$+|bBc4gz zexcMUIe64plIsU^cPK;8pA!?;Ozp7qRL|}hqL;C2%6ok1*PMTJ{v|O_Iq~2W*By~vGeUl1&}aiU*WE|)uaCdn3t-?hIt-mQk@?ql z5Fpi!XMA%CSIk`6X3d@~sG(({qcP39*J0HkGbPf~PJ*nT-2c~fBFP@LvC+<_K51CZ zxXr>gXbEH<<1qT>UByaYtEQHVj8gm`HVERW2Zc=W^GF%RHCqs6GG_dMli)p(=9Dv1OjE`h%{#3kzkcU67 zes)0=)aP;Y$ubI^7J&bNGVl+VwU7e3ppkEUMgfwUaQ}%bajiBW&Te4I_Jn~76<&Vu zfaX69ZL35{U-f^MngbX;cWw3g?FeZzSx_-|)!I}0z?Hs$CDL*aI=iH$D$YN)4hq!A zrDNF2qcbe8C%iyNX3zA7Qiu?p?9KgN|HWQ(%^U&!e=o?QfIWs1__e}8SGRSx6+=QN zH}7$t`;U$!2lQ|^%b7kfLwe!AKF26@a%qRL^8CLlPN01eMUiFq^KkJzEXFib@4dOx zG54)JivJ`R*Z~A`VI({7Y8V1##_(85c=9lRFtP^r-#fB+!8_c$^G%&EAF;h}e~T*; zu*+P0q*taI`bVtc1a7TLOUu<~Y)SeEa#>j0%mtX9v^Em~Fj(E`3*D zKvlEyt3RTEQw}Pivq{x}XD-tAk%03KYy=``3YI$MeAn)TZP@Nez4J|9zyk&u*{GTiaxOZvw%XK_C_3Okj5J7`a zJF(Nc&q2%ZLP7Iq*&8x{>LvJc9y#6pwegLr43OC?!15=*BeIm6SvP}JRFBOi}Ttk+;j?C9s(Uq1pDNOy=2dlC!)Q-2U8t zh}fKKjEmTU)|YmJF0#SY$++@Auw@?nNZK+zxASGldi?JuZ~nQdaFgr|AIG+L3+m4=6N*a zUE}5>55?yxeQ#eG3K-)J`OihbIOUJe#FWDBQ6gVBT^vR-!Z=@fArp1%R)eVxrMghSy=0Od${C8xA8oIzV z8N5K0rTMN@n88#4#AkF&;2IFw%)@h-KveOS|>uK z?u@IyJ>y@3L}|jmZitI4=O1ODPj(4B2)UolG9Gf2_Si_(zn1R}q4R zUws0vs(voj1}^)v@oM`T`@4%RK9>6f|I2hwaDi*kj}kR~=EAsGYVCb6(5~Kj>{r}3 z%hbJn@iZoi|1FQK2ha{eHvX|FFyVJi8a#q6Pt?Zn-IlApsQ&=3 z&9Whk3f44oVN4H%|IUjwCu;)@fno=l4tyW@|E(1~K}0-{3mZLI0&#LbbxY8h-#(iS zt*_0o(LRyW@x?J=hCiE$LQnaG$fU_tmjVB-ZI~te*#Eb_)&-pV%h&)<{8tA$ z!lGO5E#Ugg>jF=++hbYh^isvzv<}N5)t`D{2pP0bsDlLl1xR3ivHSb>vdu*6DUnpR zza|QGDYPR@DNELykPs~|+RyvrjoxzqcZa_kgRw}CuBC>t zJ3MVXP-hm5s-CwknV>rbZMi1Wub6}U%?StZphRPq;eKm>`(wk@l;xWrMZ}B8_jm4= z&`%ePs5~rOa%$GNdSh2{MD}w4$ZMcco>XnI5S$r73O`M1rD+MldvZ?fxX|hZ|4h+= zUl3H&*CrgbmNuW*4e%|d9m>`|3rMv1Ih3+eE_ia#wnBK-dq;P8Qq0_y_B%#mHxZjE zvw;i2a?xI#Po(V6BBzYGTL<3K9_4-(^Al%1*D5BP+I7n``9>44McWo`BQtJ$8CP{{xis32s#ApR;8=2iIfO3HmRI; zp7@dD^%j%g*eAM@`bbTM$?K<_pW2s@+lXWsnTp=oQ#Bv%70jgk_DxZa@WMwR3W$G= z%O~b4<+!^^GJ?AXQ%)g34w{UgK*`F$*|;Lcj3VDa*a{>wq)GpfBJpx z{NU8Os+wuit6`!&f0jqZ+6L5Sua4^9oW1gn1&yMBKg!!*+YHQcW_w3Nmkqq$_hDHd zK4ogU-X_L00HqJNCO!D%x7yyOP5Z0Q&JvYev2)IwSE8}nt}W+3EGc^I4s-HaJ8yT} zm#5g{gFb?+Ko87s`=h|ANiGv2vh!qaI|WfkFvq{MWg=ldW&XtT1{QJToIWY_#j9wJrx z%rBV`5%AlYLYzOf^r?|QN%HOygs|Red1qX113Pz(NmpOSf*ZWssFG~J`Q$Sr1C`JT z^Y-aW7`w`fpsD2xO#IAR#fw_{5kx2IM!y8N{CIMOHZTGgV>+YGpB+_lkHX|b@Je%^ zM|sO%O3I<~=E8U$4&dSXce9f$@G6N{d+awqD4CLppLd3T#(3zpgg{92;%9v*l25uhSu>YTdncja5SVU=UDD!#j}f_v zEb_U{wLZCsf}j3Qj(#BOf^i16KAvcIs30+3MO!3lwcu#BuzE_Qw`Ds$RJQiY<)z%4 zM6yWAK=(5i-oK|MC6&e+QxUkf!Fb{BQ-O%L8ak47uOUf|!l zgi3mvkofu(TJgn{jv&ZVgIRKGU`6rS1;Aa_P1ud)rLFvJH(Wtq?=XzEAX=w;gvBn3!H9st2+4D18hh&Z(Ca` zB@mfO;8&km=WOy!V4ZjO9j?u)9`I3a2$?U7&)z`Ng7?KODAGNC-uC*;Wjh5v5@BFT zfaQDaWsh--y{Kb+$aCc(uP_f&SbfZjN^?@zX25EPHmKMT^t%Ke!87*3a}2ulq@VKc z*E_0~yVMZC5L{j1~bCjKndkke{;g`UsIaoKXSJK&^k_eB@kQbH#_u8f ztuMY$YgYNZ!E4q*V*D(1MRX^mSUK!RPKa*sX{$Q!F7#^@S?+)K)#?cdfntf1#ed|| z{;+4dXG7tC2QFY25Zn@;IViMoSDLRgW2#4JPjpkIwaLcyV}`Ra!b6ElE7la`R!F8S3f5>!ebJ3u6mP2gr|I+EQL za4wc0272%;hc|T0)>pHzKX)1=xClbHE8Z70r#zi*?jAL5x&}1s33^H6;I2lAZ^Auo zv#*}7d~`-|g?IW!fwgHWV`f3LQ_p`&1L00<4zHX%e5OivO|rRl|HN`FkOB#(1-2>k zI{SwXL&PFoZFz2Gpbpm(yh}H5X?2QG-!dc^Fhu00ZS%Mjl3Gqrm}7xeR4FBw^Oo1p zT06*uqFCS$$JyVu4a5h~;JFpcJCCA8AH$<=9?t2#34ST2U@|(<(>ALw7k6&xq3<1lf zWf3OP+W;+iT)rsXmX4mx;n=$9iL3co4P+N2+!>Ne^~Q$=PF#V{G7Pvez1Y4=euv#m z_mvR$mmSr2eAQAdja}`$3XfSX=TDy&?YjEZK0QSbGl~s?OZ3JT0Mren_ z#SbnI$-Fm>)7qKJsXvLlVAw9xmJa1?ukdgZ24V)W&(+Bjo(aM?ParaKgLCR5xVO_y z)N?$Y-J@l~K_w5-H}TENKas5I3!GMb&I}^ug-KN#XP#^dcDO9YofHBCy4(!e~ zp|T=X)bXbzcbK5>XI=NHe){6#g(hJs1(#BhdbE+k+0i;77WM__-HeU)+c<4e5 zTqQnkDbN+DYd};zFJAj6{0N#^_gV}uFO+LpPwrt+N?@&Su+BVUul@W%bO>K!`=zGU z9gxAlz*XTpaQDKy5!Mep6woC%tDH(bcacjniVtB#{B}%c`DB^`7+w32&`*Cic zsqs?pc$KNZFH`cF_4jX2I_-^SR!v35(h2L1KY^*rEV6Tt;29Tkhewe?q4r*3O(X(! zN_^~dN-9kQX9$gz-EpS>QO16pzpkr-WB}iUunSl*Yz95OZEft5i*`tx-;Z&}jXIg_ zoVw7~T_3R2;I*{Qo00tPWTdyKQo6qI+c)y1r59kPr8qN_!5Ts0C_`@b=DMot^7N4H z$!Fs?h%3!mZHH=~G^kH*H8jrOe5oXSI*K+5}u#jQ?Av|a!gusrrvw+SCuLba3uHR_mQyr7=2$$!VIq@x48M-|bq!z+1LlQPr`WJza2hi_%4_umZ5 z)zCJFgE(#^`-+WDVC{|N9pBAuo$|TUh(*enf#siPoP`NLxBsXeIGWx`QFA+Egx|V( zhinPnD}Ivr7}rW@z7!5D76{G1`cP9qFi%NBG+H#D-casEnk0?4QTTu%O4nR);-|s? zpz19HqTasm;hAxeAtaOrbtnbtkWgYoNtJF8knWVO84(d_Bm@NM?(V@LBt(!9kdp51 z`k(RM?|q)%J737h!#Vrxz4lsb9}Twfd@cG62pD=b#T1-fLHs#n3>(69)p;sy=EDf1Z{rTUxLRt8H*SAcTU*k)rziw+EB1}F2E9HZ(Kl{9) z4Ay$k_b*X`W@f3Zhnp^6oGMM`6?iT8=!5(BS5PcPcNIZSSg&Ss>rhWoHLbT!JBaCs z$Qa(PUZ5VVb7*d>hcu(vsWE80*&F6KVg*LRxAH>{^6K~5+Wti37v}0?xM`B)k#j2s zZ3C{)vk+B-@38VOj|PKQDUcxs66x*)wO-RL`6f}vrq3WFcO1`}##VAu?g2zbf^n0* z@mxYvc5ql|LAhl3R(2 zFrW9XP}?aHY77ltZ4g2Iup9*bTbwhU+;L%mL3gFwr-xkGm-2rtK##=9s8-*l0SstH z%MnJ&L4`z{zuiA-D*2w=AHP@*m0phXZ{QZ&d0cg_Ha57|(7r+vbWDzFc?UV*O) z(3+k5dCaEa*Za43a6?^7h>$D89rci+3L@*>^`Zb~H5d#5XGb3J|HKb(rZsg;z?X(0 zf03e0RLdZ!(x!(-h{9Vfxo6h=jp3*iSoH5C(KGwCFzklF$HiMWNd1&%%9@C|4-=za8fuCSs{X5-=oWSX9QA!;HhDL@vPTTVmZP^dOf=}1;8J!> z%Dsf0Pr1I|o0P`LJJgJR$U=%W1y*3IC#Pwn)c7&K{4Vq0^ zoC>eC15nbyFVckGDS{aC)KE_8MqZui-Ti|ml{|ib@3_CqCRz8JH$TzT>9vwP`WvT+ zkV~3OimV8aYwrhGmApBUUp^*?gs`>FAEgV0n0ru#Hy^giY1=?{!$*_E^Lv{2O^!pd zMs_5;Jm>(XMraduOk&Cylq8R;*6Sfb*e%mF#;$tELi17L1fvnIdG%P^A_IRhhRm|& z)?@uv))de^dR*q#@{#R@OreZiF=NgfF54G{)kG; zzXUYsF#Pb|P=mrq@aU}jpIcGP$)zskGQ4$S|DoIsaa@wu83I*F@0}AA_KB!N^ToX6 zaKShH$-~4;f)&~NubiH{eV69*?TVx|l*L{Yjvg#2?TXjDy@qS*0sQ7_Lg8A5M@&DS zYgLeWzomsh_Z4K>_j`y-4W)>zJXYq{D?5JtVY&e#L=%<$sw;Q1u$fO#c%R>zL-?)0 zvACyy)x&6CTn#rHTGw6^Ga!|nn*Fw{xg0Os2=cW2WwE`rje&5G5%oRh0OUfey>tk?VWW zzt$UR`uUmvxz1k1#Zo)xs#Wjs0bvE&dh#042jyXbBlmI7pcH~lTla#NdgH}89TB0T zcMuor)gN{MaT0y$MZj+XLc#y%U#LRFvEm5o-0<1E>A**i7zq!56DrH#>EI*38r;tQ zfoB>s@tfg@uIj)HSvHwtajEf8xHlT^7|N-aRevw@^n2a9{5!i)88)r@9KM^M_gbSHgP8{r!FBbc{iS z)vBVdn(&|19q4!qNMk1}_O;?D);BW4B38tw=A0lT?y%x(`TPN2=L%s-ah>&$Uf%<& zy}=U6yp$xbiFs9?h3fC!?!$P${%!tuRq+FHjVwFCVMBnNd|rQbrKe`L>V^l$j_9b$ z0z$1(TxzxqM@8|3EGM+?=!@CheAg4=e^TysI4P+-cE$XOrfcgRf}HxRknEm_F1IqT zXXr2Y$jLs}Ln?aD-_bRBC!ISK5JVaN)nzn+-d74!DB`J#ee!M^m8uC%_{`bs?TjRA zK#^qZOB~BZ-w^h{6}mR*Q(3ai@4lF~!+zsnsrOB{y~tr!P{Vzdf$=ubYb@JetDQbi z24s%Ag|Z|}CUtzGz+cSVud8GasA9QT(l-!{DyX;MQq%$Hy-~9;cVu5zH{-~R{5k*Z z#`dB-o1U-&Z0djIW|M3T2cWL#fEKnRJ;}=SE$m8! z4dg|Rm}E>ko!#3905ctXv%kWdSk`i+|Md0n>4VVd2D?FLtLUl7R_M*4kV`jwm&|Tz zXvfwG3XDCYHk$b~Hn3UUqdDx%58C@k93wsDRa+rk8;M}k#-#Nydu5t1{yIbu|J$T} zKG)gzS#ab4^`>j|DmRIIbfAYA0K5Y!<241K713Usu3DgWy*l-3baUzY@}-+6Mt>6{ zj%gB`V9;K`U6O|gd?-C8I|q&~;K>uRoBmykM^hEia8VpSu@|#f(6w+7K=7(jEd)@U zlMAGS%_6C67w1{;6wKj;SoixYFyj5P8EbeafglCZhxm6pZj=NNhf0c2R3prMZ^uIq zHCqfiLU&eq(K@0Ifs^8ts1onNGe2MU6&S-sYeXyy)Kq1pJ|g6{`9S}Y&RxLYum-!} zG9}Uiw7#MT5_S;=Mjm5SRfpeoc-A86C1rpgAx-An8|}CPgv+hx!NUVrNc~>5LcQ<5{YZ{N;;aB8uA*0DQs1M0LtdA6 zz9@Sm+J*RAZB{|G;dd2zorM3B%CE=(MO9#e4>?$`D=q-3?RWZcjL2!&?ePrVIJOno zhm(05uOM_XuDy=aa=C{)Gn{H8cpRl^dZ*O>_u7zZ0>8|lt;|}+axq(gF92NWq4#%F zpY`TO&l+&bk3KYPH2h`)#oyD!Fp4$v9-QipkGRn9YGQE83CbR5bg-4JaMcT;(n!Q@ zBTQRIY0cuSj6fVu`~=w@PQc$0mgQM%KogF87ccU~E;aFG=X_o8>A>DD0mhO4e-Ghu zZq&a|2&c3CqJk-Z3fL&y%6!rISpEVh#q_xOT&(10#?XG2KKXj`(9xX!cUCnsQ<#%M zfL<&|!wSn>`C;qZk*&H1wVb6d%S}1HW=#5PUOxxS)Z$|vE#;X}jgEE5!VO6P{%r&R zNq>m|uqn=|u-adBXlw)iLYgWPhS2BY6_jw>d;Hz6R}pf?4$cphfoUQf{YA1u?=N%y zWussMVKQEf3Va~Bx}X6V{4)@QVu6+O z`+UdG*cUzUJGP4tJU3O?Z@*Y2aUD)}&zzh>>DF4*G!#KhCbJ$6B}N94-}Kxq8y&`r zWI-1aA@%TzvF_f;k5r#=WcVq#$(u6@c?k7eyp66EHAN5(n@53^eAb4HLK=fiNZ|wi z`a8WJlB^b-Ggz5Rt{uC2Mlkz9NAg(rA4V$)D~XMb|I=QCKV4yI`S1^y{q=mB$q!DZ ztpQwd`GSoY)CA02^6)*ylWxGwsg;lygrP%`U1bj?mAHj>`F$sU&sJFGgqeyjD8OWr zb92^}ZDM;%z)}UE21iE)RY7$g%c1u!zS`U)*nB>>!`CoMuK)poR|Epjwjt$p4ij&s zncx^Lq+Ib+?=H1W-PXlY@AAc;_nj7R-PC!EWEN?wN+y~+>7IRWMm+!eJ2si6BIRWL z=W>CUQ_Dhjio1sGlC!SGe8URN_V9Dja(%LJ9Cr{P#32|mfTCXg;`sUs9f)$-dDA-L zmec97Y5t;bUE#u_>yHW}Cvpy(H9%H@+CVhXR)NkV>sjb%`|pPRvaW?sgXAZ45Dw7} zZi<@4b`8^bbiI2zz{5D21n5;lXOly*pXcu4F1`Y|pDJ$j511WwQbqr4L~O0D{+@da zJCbkSl{gQ(Js9GzcR{6!i6oPG3gZYHO=3S5voTc5fg2R9yvpx>=`31+giDNsNqr#k zTY66USrLCnQZ!2!{CZaoHUJ(XlR$n273~tyVS@{eh#(>O&6r>kA61B*UM)5AFsSki zU@k!o0S|K$ouxkvE1!_JCV=oo!&^A~FD|*t-G+9Lau=`JcAm`WZR8D&TpMqN`hKsk z2o83^ZBjY7w4I$!*8%I;A&|{zM}8~g{P(HF=BJ%sk7NLAt@^?c>Ph@6<2^3?LM}-Z zQsavqtDXro$C;ZqfNn|5BZ+RJx=x!}gb1+X2xSoF=wvB5#4ki)5>17u_^6AS(C452 zXUUc`w;i##RqkcI5QaIct!zj3Z4C@Zc^rQ$VfU<@k17$Hk1kf%0Z>2CLNWKs$B8tF z%`(!sI{_g;vBA`X!yFjDG!W5({#&*f{Lijr-Js(|$;RIt%(&QYW=uiaW@v}7AzV7b|Y@>rfSe$T&XLrTLRQZw$QR2tYlK$z+GejXR6o* zexD!lZ?2Ic4F4-on9e`m#ni=ju30LoJ?lEGt!)b(Jy?J4uwNBF*!A#`q1N)Y88Y%M z>bpPBjpILO<7+U|blNisuARk6I**l5BMMzUh`N`hdyUWg~d?cbUwAy23@#E#D_ z2ALxyvZb^+HQn$_qlxN=>4ymY=9JIX>lvk;vdf%4yp6}GqMiKav@Qvqpj|3 z==?4-v?dRTbqyt}Tz@FEw)@x?Vqzf3f?5gr9BI|GIGkSa1+SEQJd_d?TW zZ$;_y*5Z3k5`a$rm%R<>c}sAbB_(LFa}kNwWN$Q(uln8=gc`~;K@R5OX( zXunkWWE6-A7KSGU8b{#E>a0lr+j-YC2IJhtw-RB@H&H|q01yZ#P?ghoh+k9 zG2IAG_C^L}&Rlm-`_fc-CZ{fk@L{=jp*-U8%SWN6;z?xo=T9r6;o8DwQFo!l2%yHH z%)ni^gJVHX2?fQAxj7B9MgD5nzd@s~*%?;SrGsa$yHre3hY=RIZrT&Dex<3oPq<+K zmm1w;HMIMG?_f2Pnzym)+RPd_hCm?-GNp!FokR`H^c8y)DM1eiI2Jt97x#zY=+J?# zile5Cee*S1tN6NaN@X*q&s!v$_HKm0vVYZ85&(`A#|(?XRfltxUWns~ z6&pD`9wZ4(urcM!_H;?*s4p{gknshMn+iG+)vYX3EAtOKV=w%UE3^gy15$G1{D72g zSlF6R^jd)-1}K#;bz>BD^=>)MYq3Z4>*ZMJ5c5V9XU8Joj395nu}31{zY)Ys$q-!& z>faPH>K7^S1Q+-P zm3-B~dU;~8C~rUcD|?b*f9)5y!D2t~sv=;|5gnT7e@epZv_}-Ki1$em6VgrHQikRl z8~6U`7AOWE{kTM0ka3To1%v8Vdh+|nEh~How54#i<-Y{d_I%_+79rqA!C6?P zlI7LNHd&2ijC^C2>bCxRVjA+@3%O!+AQ3)ue0u}e3`432zFyBoi&Z#88wI>(`<4%+ z89_@veZzMs@8@~hiFv@r#WCy_bORA#pvJ9{_o0$G`0#7fzqkn7;(cjNKzAdv> zK!q)D9^Ql5Fd%9vXy}=ruk;Jdm-~pHoe)45e_4tm?2JkG{6}HfmtCA8}#J91Zg74Vq1P3zOV5ZA!td7 zQ}On$P%eoS9|Ot>1Yoan%G6%}FTSl$fgyf1_N#i+NPR1#_IP}9Rc{eNw`qA{z*w=v{8zs>z!8UYS&s02a$e>M zzM788Pobk-^_RC#n;6OmLdMe}|Gov#llFVo1*i}$@BPj%K<`xImoUl9TiWtUOxM|X z%9t$`hPy5?EVh0#R&8zlOQJZfk;9Te1032qh-l{v_>c2h`0MIWbID7nYTI`grh*?; zPG%+k#0oD)1>IQm+bU@^zVpNYUB&u&@ehu#yj=c}r<+aqD0){QL~#H6c@)bQSHt-; zng`cFK}9O7f{2zE;eLoz#{d=2A?@20*IYwwGnR`b?0NX{Qm?_@kRRc9B~(hE#ICf= z#m>r+wJv8yC&M{>w%6g{wZY_oVSSqag}I@*;jeS=`JJdRAXUfIdlyI&E8r$_{x?zh z6`sg^ey-g$;5o+gL}RnF=fc-eD!}&Z#e}6CPTr&Bay#AXKo1fcPBhb>~{tgWwS0!`Au&J_OOQMFPBj?ubz^E z7TdW1wL8(^i=~P(%=t^FB1FqA7}yupeQojRS-3Nr`y3BSDz=W(3OkxV1;pZPX5!ywpl zPj(GItm*BFp2d7Y?(2KF(3FOu!8Hvg09gESp>%=pLY{-a#h$PyKIR70Uc!s_kdF=H z`|;?N*H%`1!&um5%J|4R+a8z^mF$ggakpPk#i@UpG4Y9;b_Arse}hR! zgomfwM%CR!4-O7j9C9;`AA?!Q`S}cY6`qQG_YE64MzVA?C+9(>fsHYsV%SF#1v3M_ z>QptA2?`W|OjBH~YW)y`Sq0OAro(fBBKR1_rfWD;)lPP2oL3ryScwn zlleJk!qk)?CelKDI)SeV3&yxE+6K@lepvb94Sbjl!S005d-^Lb zJAL>2Yc**bgHXk=^21q|NnhP>%!>S7@>i3adXtjA>TG;ueJ8T=G<$AYOS++TVz_U} z2I7drJI&zg9#Uodz^%z>CaR^mnq8`2Cl(a&S6Y}A*m(NhO#o}g{R7Ws=vG3)6UpUd zQPCE+FmZ|n{g%Pq1Dmdu;$rm+9GacL$>;&T^>xhsQ8$LzRaWnJ%$kkS`A}>L_7vsC2mymv-*cOxDwrV_Ed#f`hiRezNey4jN4&2K(dpc zV?Ym4N6>+lY>F_S9r7Cufxw&qH~FUF;>`^VB_18~7mnujKhm_Wq|Yz6v~M&%ynf#H zjysXUj5c&scEk=26l_G{oyg-1?*gF@!l1)Z1^GDES&bGph!B)LJL@Bv8}ZiRlC}%GAiNxF_Wf`(Gj? zhR(|8>*>1@O))kyI8P-|m2K2;QxQ`JEu6oEGaTj{gm+!y9Tr*huJ?CWfB&5B4N<_k zMZIuywMLuFpqHcOHOxHzs54IMY;xW?d4Uoa-fP4`6Y7*JK=*a^ee@KEU2ULs3Gpgk zqQKt}`lsuPZ*0as_gK{nFU4myJ2LUoz%_+HItGHH=MXu(0$V~5+tKzIUq&zSc`{6a zB(984`n$yRk!-fjelhrLQ`8?V9N*yseP z!3m_Y_gP^vzcui^af0|a9a$5k!+e69MrAduH=2}6)&8B_OLYT(*=FM<2h1`#0sH6R z^fFkABNqzRQpGmMNcN_sKDZ3U#7GRj3&G?AYPm%B!!hp9B4G2s_9;CIMpykso#me; z{tgeI!~yLvJY%3y1O$-$X}hF?{BSR@C;<@O39ceNJ&%nncivbhKXaV095GD5#Sp5c z2zWqneDJD`L-iLIJ?5;xmi<{Adr)&Dr%8w4AE*e-Q9YeeZ8#(nKnUp5cV3qV@sb~b zm&Vl0g$B2VCuu4@ib>^+fD2~v>_?mm>jg;GR z@O!-|SvtV(e^$k+dEh20*uZ|J3SOsXB5OwbJkar3usy_FWdy-QmJ0j~P`9@^k(zW> z!os`dxy8M%uEqAM7SFeq;}3BL-Ki&NI9fn~PzuQdO(?Hl$f#KuwA2OJL>{qO=zDcs zJC{5lbd)+jbxQuKY~R>%R>H{3EN43KlSVw?lqNzsaE(;6ah46se@5Cw4>00$`D{rG%xV1H0K@wq1!qv9R^r>AfFbL-Zk z!Sg#{k1$t3(NIWrB7l`40Cm$$qVp*ccUF#!An=J=MLOSD4NPP$MjCM9WI!^+gOG4Umi$dX2=Ga`sJw8k7FsAP+GLVA@P+$H~!jTje&XWQhx12AyZ{q@32yCHkB`}g8 z@MekdIS6HcIlT%+2ZE5G(vJFAKa>k%lstu=wASCuA|)O8jFKK$f|$Wg|99Mv+$3MT zJ>|lI@|-xB4^BD`Y&rRh3RO;`9it4|a(y$eFEFlBMXY-&XUW0I$UsQ@kbp8$i54S$ z0;2IIwK+FPee*Zb2T78Epv4Up@bZ&I!h8SZ2?XknrWkU*xbb(H|IX;%#B{*B-$X~0 z0*DZF$E*$a)2rf^)Yzl@nkiNBMPui{vgd~9CUC#uIHEN~gDn$tI;Qf!iF)YDvZxm80tH0d=mmbXapo?f0 zn8^~*J=EbHi88eak!y?BtAxFORg^RdOiU3jU$1fq9cYk}P-_Y}@$ew(7%mo@fMo;j zKn@3wqsJ98$Z9!pAszY}XpHs$W)WKxPgVcdo-wSuH#lz?Ca9eJ$vTj%=-&5wvbVmG z*hyPXnbE^2pwQ`)ZM!_B5604!TGk!XPkk);z@-!1Luh$_I(sPv>?QJtKi-Jo4bfG_ z_ueAN7ddgw%AMONP1_0^uL1t;d2>U9(4$+yc@rFa9*HGa!l?b>$fr=is@p zDkqL7RXyq(j`!Q0Ot;@4a4A?h2mJMWdwKMjw{Rc7=}3AxbBOvt6xCqIF|m16Tfsq< zMW7E+2SO1f=|V-_gL8X4Ar-#CF&U0^7rPteIWIfL?d1KiK8UCJmXvP^WSh|4ZDaMxm0iY??` z+5^W{oseXmTYR04Nxr-D%>WI0mI^Q1@FCi(5FvvPpcI^e`n@ashZr7B7Rht{agL74 zFINjNLZd7Ad)w37Uc8y1iFk}XEtD)#yIbnCQxD)t}h$?!a_xjs=}r}wKq zm``K3S1GS=5_FHomr-@$M7wv6R)2*r3k=@>@qL#p?o|BeJpseq*{q)~%lFRSli|*C zN&(z$4*AXpNJr?==jSJZf}6KuNg`BE`d{=t(*_Jfy$rcyH0;TqV!57{Z43)x$SsH_ zCz=|kz(4`8AV#-mA-=LGo12zfp3J^J1_|(O%KDy|%v#g-WsTYbP_Dv&%pzvflHI*u z7b2A7Vh@M$+k6$8?o{7E+^c>~rYBaf6>v|3DY6^m=LtNWinmwb z#PVyZ9yC$KyCO*ZWb|Y)i?@^5X<(x2NdlhmeWJ&K!?cdqdFJY9YbYcG2!ZKcyeK4D5qed_A7o1XYuNvA57kh^Jd?p2Rb-9IN!7i)#g~{SGo1UL zKZ0~O?o;nHq9dHF8egr+uJw<&;SF+jFW!f_xfBavTtx+ZS2|0C6w`rc+wAyDNJD0m z)PF{PFXJ*LV3}pOolML70bQsLcm|D~~p4`(Fu6Pp7d-J+3n5Frz z--3~E9tA$MKdf6K&CKBX45kFSSPRx{98?%`nt6U)Kl5D?=OCp$GrNqY)cJ8ce(785=_JAD> z7M$H1FDYO4aM|5{+T2US=4}Uk=c98{$T4XLP{lT$A9WM@RL8Ebr0>84amDDhGNK7Z zjL2jLAS1aslNDEA(REHB1h>OE$t5zdtUqAbOdTdD*pQa{gU>y-e+?S<6+sdhXO&aZ z$xvuG!#;4#)SzG(;9eUp3VKRNWjOK0 zz`>Jxv3cJtRBz)u-f13VB|mIiwCZ}5$!<;Pi`wB*8q-?eYbK`U8kPyP=6uz$??KmC z0=hKwC%djkmo1l3@k1*xM z@cX-u+zPuK5?Qc9p)D+7uHg$>Q3waGB6v1R16=1qg)j7oOm?K(*@Mq9MSg@#3hk`8=zgffrO?<~ znkqy2xosP>(7BPxn0HL^uG@!s1B)dSQmSP%rm8o|;-D%=qwA2?EKR3Qi0Ptz*Fag3l7k_VHvouXnUdcvx9W248k^@VrwN$qP-2k{3O=>*S|N3|du%u-Xe ziJg(nEIe_tV!FqYV*#zkcC8|y;nBPXY+i}PCCrhzXr1>Zayy5gRUR#ky4Z&H3~pyd zGIK$x0-h8|c2&w$bIf(fwul)FgHfP@{jSfx+pEnC(aJYxC)Ldnb33BxSwH}rGQ5T3 zQ&6)oPC1RI`25FuVJ@*%AR=N%pAVT;3snV&Cuj-j4t^NBZ;ka>wyo(0(#}LvmiN07 zYNtKlO@~*1H{d3?a!0#&xujEpqbhThfnTGnTVPGdHPfxQsH_s};>3RU_3GG9hU?!a zAP{fN1DW78n;F878T*U4hLRCzRfsaJHw4fKn3)W#`0ID3eP*F%^r-Mk@Z1;Hp~+&9 zjaGxR0-dVC`awSdmC$UOUz4;>@O)t6>0|DArV3JkC%NNWaPJYJRaqd16v)Ull#8i$ z)m}xgboe?oNmKmYpk#Dd*|TpSlFLzgO6>C;dg#1~vlBoRpUZr}-TFe%@fzKwHIPL< z#{ig_UsRJkvw2&&O zn(BkSqtp@A#{vnA>P@0|nIh9cWKnp>3NcP8pc4D%) z!ZhO~h@4Y?4Q-XflE#)Gdz&e=NDn$12gulS0!_1#Fx~jU!y;kHfUDZajyLofvb!r24r2I7;zhtBg z&M1Ho=$^qun}HeiB-Phh3%U8N?=UNyAB3g{*YR&TJ{5=Y@m#i&hyXOg&PZLo=8pE) z!IB+s98Ul!5_A~V0#oE0wAee{-6eZUh-Aj1mz;n^1@>%*2+ADSBB+9PKR7fpYy@3 zD!;R2y==8;r)3urYi_`2?NIQ3;;7t%35SOOq91?Ol3Ilu$%QkvXu6y?q=oWj+&@3b zTBZY2eA%wQc+AseFirI%K2*TkL9g=k*$;CYF}-Tt z-j6g``EfC)pU%Wrvcj?Fuj4&*Er33P$x;Rd4rpE~7C+VDUO;(@$jka!(Zo}9T;Vf4 zRZ3mTCzRz5IB%0z*u*f|Rctwp>8g0G^Xc}&!0DM4U<8{+EC&3{YP1c;Z|*#`P-Dp3EhkULOM-Vf*gWrn+zL+H)<^uEVp_lW#EB*&&z@AnDKkKB*M90oL)1 z_~wEA0PTLYXwg+X+t9Vg3xsg8Qrysj49B^P9jJ5F+U9tF`IIzVQmC?cLj?LbGnrfB zK{5JUW}&p>@+&6F=0dV3zgsKG?pR*Q*r!C|s|ErrcRh{NZs2@b7N+0-%_14cjLwt+KLm9P<}=-qnPFv42II zVWO3+A$uLksY8N;`_LEM+*vZx-k=dM1{aSqKNtNMhc?%N6{^hN;SJOzFVv1pl*<8b;CZ(F5r+kq0+)Hu)~KFu@gZQ{I|DPc3C4;$ezxMkT0x z+tv78U5X2S`xE4={?~1;GmlrM4MBnxEI#P^#8qU ziBLz`D~RTk|Ak7{`RQ>eytv(u!n9cy0}?+-^4SV4usfCYV65p*ewejBVQ84$3!UGT zX5SLU{$i;Alwdd&?`T6|Q28?Tud=g;3ijX4VupV&Ca@m^{C@%LoF>VTLoknJt#sDI z`mzP?(RjO*gpmQxgv+oxLFC*pZoJn*)Hf3Vh!ORqpK-qH80!E{sXE|dP(0F#80~u6 ztfQ@z9#b@)F9wDv^vqlb=Cn~)rK>9>7=lnvxNy4*!mSuLSiMbdsS6qi6{oR&{x-n4 zvbi2;)?k!J-@*V`bDuN`Jf$2r_9+9Xu**&1thzmLM0D)2h$Iqf7~xnsG3P?AEPRa+ z)(1>8;OzG0wTZTYNf7L8=^Jm}Ij@5cg;wB8D8uQFYyB`X-FZ=J&Cn`=q)%>z+UI zWuh>0a(sJzcl!-Z1|TbMEZ?Yd&l_o?eW8Oi!{eFq=4jy;=zb~7jDnMGas%e}J|87A zG8|IyQ|o7Zk-*NNI)ygZIQ{WKO^vRYYc+jxLV9oWKE`zjJq=#VkTen~scr1;LH0%% zKIm}pg6mpPQ4pYSkof_s_A}TtLNzBQ7_f=ARtQ0#vZ0Swr0M(AzAl8gl) zTv$Bmyo5b$ba-5^5UT7>O00<;p*1d5{VN1GazuWwd0fLC^BO#VAS9e%i>JMA^4!GM zI`SE{vBx7IdHpn5x<~}(hZTcZ`p+}BCra;VR>L?2yj$|GDuyejHryhO*peQ0fQOFDBm##+QbR!o z;kVWgKxKoZoLf9I=5+1B#h)?7~Z7x`qcJ|f-PgW34E!E?&$Qi<00t_`mS7G zI$v(#00vKj4o>=;6%zNTyI6yF`g5uf$WmOR8>{XC-rd!tl(Ta^)}Ytjq4z)D^IrZe zK$PEbW#ZJhr%&1U8El&YG^?&l4J9?*%+y%XBfSHRvhgmgT$%GPc?OUXiuP{t10*A; zaLk%TQCye0LM`<)qC36;aOk_<*>}p+^KOJ{P0ix%hfYx_g2Hp?d@BBiX36R4AS9jj z)qA+P#kkVVh`h71_Rf0JvIFE6b7Trw-R^M=6{8WWvN`5n0pV5g5iy&|{`<@wJ@vLx zmTvOevu)ye2JU->O`+$bw;-mw@wAX7pAVh8SrH5EKgq9PSA|e zcOY>e;PsF|V>~Z5(hsOuw2j9Dc zD7edrk;#}@mpJ$)i1ME(BU?h9pWo%2Hxm*a6ZW7dvOc=joq@sP+& zLEMHKfehKA8M;H>p^yB^!+?vhG{J)D$00p0tN~lw>bs^?4$*hMX(;Zn!hA!J~c__~mOW80&F;^b&iQd6qG3 zw81pa4Geap3}ClZmNEv*_lf_{0_2oa(}p2zUbV-c!6c!|)?sfKNLW=jIDm<${d~Hh zwS8p}(Qzfs4*Pf5Ehlj^mz4=xq4@L>{RhN_jaI<}6_8T}qQ#q^-Sh zGD=vwx2~^!g_RE2{-i*tQj9-}1aHjnp7@`~m%^CnIDiUSRBJ8iDWh}&o>}u279mOn z*)HV4y_u3Mq)zHUdtZvh70Auvm|)a0IBC_5=c7R=Cl1)ThKB^or2kr=H;50k1rD^U0C#e|D}Sz)TcM5PT+m=?)C|13my< zQz_%%F@Vn&K^yfu;ers$!04n0V*v~8cCFi@Sl8EZltk0h$K;NHM`{UU&H~etE%jS` zKsy#XcFP<>Pc|MAY0PKy-3?M7Wv&nuAuFXcqI!e--IL(<+A8FLi=Vyoqq}Ol5|~5* ze-au=+Nf%}IeEolyl@s9)$|U`?b>yk*Wjt$?>(mr7WCuV;k*_bxIWzP0>c1U{Oc7;luLlhzU%Efm z?5HVL!C{G(itDTqjOq%3!P&xHyP4SKI?l_q4!w#`cz143OfWmc`#~L#H2B!h1Xmz< z;b1!HRWeY;{)~lr-x>I2RwjN3?{yQ-J@yh+XIGe6I{gxQn~(MWnIDfyi;ER*bViM^rp|_1vfd&yqatdutX`juY;U zIY)T_$5{#e0MYydZVXh6CAQu(=S+~^w1Q?z1dfyXb5grC(VUkP`fs;9(~C3rfVU*y zVg-CTSiwK6nih(xq+pvjV;VSv8U*u3f0?bu%qbe#hV~h{CmzLoHI8&T*dtLAfc(*9 z^aAQ3u3P(SG)BY9^P56Uk_{*gt0^jI4iDUx5IOJeSDC1G<^zvfb(^ zQAl7XJuu7oPBG7nuWio4ntdpY)r8tw6=n+=;I&7O3*x(e6K^9>zdvGvovLA>4fw$~ zFfZVVOM-!M0DYyx9(wVdqz65j#)2W-gSL-J7wOgt4(!^$f5miC`!h$im6Rh=*PNcm zjU#P)q$EGmfBxa!VdrD!=Ep|&;AB;*PfZl}aH=w(Kgd8vV}~|7#u#(BlJ}m+858ZN z^JidhE1e7d+9%T%#qbNiDyLz=hiMU2%0cv>f)`OxMpr=6w3z~r=mKZWOCM>{W=D$H z`fV|K6BpJDtPE3_;q6=R2|vgv^%KJ160IIBuC*#vi&dy?_K9~Hi-Jdyt^y--q-A?Q zC%h9OxJw85KVR@}qsLvWyW8F`qb3EtE}S_!f0Lbu+dbg9wEdG6cz+rneF7F|Wx)BO zpe%|HkklB)5P*0;tE{U%H_UaRTCRzV1x7145lPpK4MS-8l1F@k{mq&~# z#p|?Hqjn4V98w(0*L(JQI3hr%(xT?hWLi&$xNd zI%5vTc3P`9xKybYzz_XzP~Z*fIdp98bd*y}{nyY3OVFnAtqm_`?W}}fx5ay}=n!WU zT$1$3xzAC95>^v)=wc|y?KaPTr_~by>IAwMa8NN+fL5`(S3s0-Y}ZQJ`VEk)lja;P zZ1-x6B5|uAt3=wr&Z+v&Y5L1LF%$wb1I0W>WrhxZ6?BO9Wt4!CQ1HEK8~PMa?8Jtp z|IpPo<6~Zn)#8u0gYZLdqq_+r20Rt}vJ70z3ZNK(>xg>NBwdJ5NB*GE(R#q;sio5P z-opRqad!;u*;$XgIV5wBT{*w|3ZuYF)f0+ZtKL8NEygJxu>+>N5$yYH!~4rjM!097 zxP738B7|zsoYKR#D5bY2LokhYwJS@3muNa9_)CfrId76sk|;D-_KvHpw| zghPd454RC5dK-1@p{kk`Y$lu;SZ@hytn2T$&`sktnAvf3E7E2|XaV}d0ih(q69>CQ z9*M27SeTDtvGeKuZJC^MC3El@Iht zQ5Q`TdoD&0!^?o^Slr-{^dHP;sq1_GkFd1y<0Ev8IcE>0db6r#?Zj_^C*>Y+NmBZU zmKb_AMgT7|4wqCIT2X>|MZf}Rct0;wrXA;Dpjx?1aYr!Voo%SVRBA9Do^$;XhuqaY zE8&>;c&hC%7l}yPs)ICz1h6a=ZdqqxBA0;a@}I8XqQb^K_6oNNr{Q}*xaylV_&Un#Ig;KQ%k)*G`WZM*XS)@x^Y8GF%%Ro|zPlK9AtQzF45{GhHCooC)fhyX71 zY4W?b0(UY)oep$ng7~F6Q}evtCjFLZm(zP;**t2@E4XgXLQJ7`FSxAdc&2yU2?-#8A*G5~0i~F;aKZ2$ z9oZ%`Z?)4*79Jh$)gXJ;Ycs;G7poFy!;=~phsXYI22S41zxJEHb7X@q{tXbhQR?H; zrC@#waN-pj(stm{smMV}96M%gKIpXDPyVZH=HZY4M9Pr3A54-JI52vTIskM=SvRJu)ZxIaa`UI~Y0YBN$q!PU_?#y%MR(oE zZvsIPfV-*h(a~gb3P$6H?{)8q>*YQz5+DlvK9k7-XwaTmpu5ax_v!bRJSXV5B8vb$ zEaNVW?~sB9ya;Y)YhI1+;%CPh71_Z5#E^=Uo&kBJhB$Ngpf&L{7!^}mmXxyIWVzC*8jeM1 z-ItFGRY&fG{dKWvAO}H5G!YH|e^h;UJe*w@?aUaAXrn|OF*+d#q9qu;*HnobEr{qu zA0qf5CCER9L{lj$iHgebv2ax?--h8(tj}dG`FU zEO9J3nfH&SOv3eD`89g|tJb1{1)-Au?;MHWb%D+Ff0Ho6(mdBXn%^V6hFV2@!@1?J zE(NTHt&z9F^qPy*z|lisxHUF6KI3Nm@M5rn6n75@x-0=~>S~wQp z6hkC_T^Bz;-~ty704fNaFsOm`e%Z2?fVmQHBQUd4m zQw3H9;;K&kE_Z@XfBRjILOwb102fW~a`9Di+Pf@Zs+W7)mjyh*Kf%EvESt((S+pXq zOSjqi@cjPS2|#>QCjaKs36+t(&&L_wULG{yt;umW!O(#FBxo-E4(G($D;6cUKOfDB6u+|Yj8ATp zo48d*(Tg4APCx`9BqsP&VD;3s^)W%6@>j&@s7{ju4o(ih#1(Cpr+J6M2S*s7=dvQo z%xUk%-4ZARCaCcoLca&3;g{zoy+)W4$WpDD%AtR-di{FEFuT<-vzKyb@ATm8{==O*PS?xUd6R zprv}g^W4@w6W zsM+J+U_QbfAk;RmM}o0D{%pKGHe06&x<`LWZO9q@8CK0(pUmGRwliKm?E#wb7M3M#Zhk7=F`LQAv%W5yRw3zJs}Oy)hXSlyKp4D!uyAt{@){e@qR|KCBCFD6thdXzSvZhKC!+u?yOdG-`da+C=8t@}IbC;#R*} z(+v`D>njrSzoo}yHdL9A$Qp^iIE25Xn~o~UK5V_S;cDpR$XcdS#-VmKfmq=#j2c|8 zJY=;p)T)?p5q+n8>GR_kC;Eit1riGZqHHr1nIgU?i@pu}u!g{Tt~a#hXsH{}wg)PD z+ZtK^TK6GV@-Mb)J$=TExW)@*;M8vzj?pF^Awm6F*S+ts&OZh%b9&n8t>Ad!pc+ElgfB2YpbdU9& zM%%jBzZ5HG*pmmht%41MEHFh9!^)B370=Vl-cGgtDCV?Cc9Hu&`}wScgIz7VRI@47 z{ywYVH&R1;&BV~dtBb2|d5wF$SMjkoA65KesQwaQGN>hxsE=eOR{PyYi6OTYqrf1|iAS^0R)^ zkok{kF9Bl3Zl(3v4_5`Ru`w4PgKTK|a=PAnJ`+Q$R#YsIYOwE%=Np+Itcmfk{o1}6 zMfbl%@|bSOFdBqX1REC51a-Yc76SW%aM9wMJDpvk4YL$TyZ37NPMCCD*5Rzp$NBlm zhu_ROOqJMJLTIZz-+gAA62dTr&(OvBS~G<(F-&#c?)hj6Ptb~_h*`=IhcL9-i^c{EY_51e{zI?k=&4?LXJ} z@3;x$?SMzP$34s8@pxCikj0uXdkl}6)bgj+5J6;K-GCGbHD+J~z(x2^fPy1a+ zdxHbAS|&(jnz$BRyevYe+O(FF{O3t*IIBV&JGSa8(a)UlX=xQge-!xN&Y{8;xPJ=K zL_BQcqG@_9Q0Bzlyt8zYK$BbyRXr|HlveFt`F0@3xRp|M|EPBx-#0oKJK{?|RP*XD zbM&>_w9akHCP!=Fk_ajK3=y~!75mLvb;z_tD5vF{&WVRr&HbpM0P27=Z;1tfPm_T-t*PCf+U z??j}d#H~549X>^#*s6ZN-sKb7^@|IW@D=-Bz!lDweCvZrSu(zf)pU|Oc7V9d|F*|) zGYXNz^;kg^bJ()*!qE_a7Fb2|)p;v9&j+1|{0k(?_u=EvOQj3yAjxa2^$-OMc$G!1kR7>)Uo2n3N zWDJ|wT7@6kvkmwTxko>Jo9y0jPie12_wn@EU9Un5#|Zm#Zi6=vxD#$PT(()na5VA` zE*l5ZmiLEoj7m3($G9#mH99Nayft$?n^?S**Oqv_iCR*ML*H~b*6TD=bCO-(?{06 zZ09`>N$7`4Gf#oWTmD$E(d)-WC!snm@kfUcp5jAYc7^R`?w$3-g5ZxaA)boS=n9h~I-n5XV*PAN(r#{woNQn)0RtBNwMg zDwT}S87^aU9K+JD((6iV1rtc-Hz4`bl$RY;sSR$mPm_k;a$tlypKJQ5IA+wlc&6SB z`LhUvqjf~VYgtS)Z?eQtLw!n&g2t>iqaRppJsYL>POUst<&lz~C{7*mNcYcQDgEAs z4dm`uZeFh0GL2LvkkH8fHeWAR({rC8k_F;l0BWno_tr!t7R!kyw#%>s=VMB`X;zm! zVp(Ly@_jyN7|_M76l)O&F^1iN2$lHRPZZT+d4F1t)m-7&gd6@kL&g%Ua^Z>C5x=AI6~@sgX^@ZU zwL`ThGkj1UJe5EQ94)8;A#4^T1s74kIVo4tCQ6^ll(%-$_FSDCxpr7xF24Khou<^B zuv1lj%>N43)8p$t@?!H#;TvXB*e~uSZk3q<|D_Au?Db#flNv?gX7TXK=GJTpZS%jfOi=w_l-Dq!Gr&Uo>w(j9=2z!gz4W zHET469!5$r7;Is4MYHCR2IeLf=Af=gx_F>YkO+l9?Pl+lU{Ek{~QtLkfh|% z(>RnPwZN2ODap?MWj zDjmLz>Bf*zH2sobo+;eMqoVZyZV zZg!JPQDaW#s$d8_7~%4$HyT1R+g?foMijR?3v*B~lvnhVV5NLF)Dg-WeyCsndP$!m z7JkF^kmyFpc~&a!AYFmUAxH2BL{bVz z*_ZOsB1kV8=mX?IX%flBAQ3sTnatgjVbc*Z|2+x%w7wzTP^xPSrSMVZ+# zNQQ-PHYiziXSlCR%cHING$SDYy$gU3dg9KlT*Gc3t~Gb_u1lNh7hRuaXR&dBgnv7= z@$s=(f(VvF0=yU?Kpuoa1ZUSpx!pyCeG7o zaHxt|$Tqp|9kLpuszeYN2!NU30pT1m;DeAzy7&UG^#+9;&HEa(?Ub6&2{E3BG^wj^ zU!`At{_1vPHQ$*brH$=3catMo{Og<#+OdBl zH%|zDf^GrL2v)q}HU|)|NkGy2licI6;7}PxY9@sjHiwI=*nQqxUMDebGg;0C;22*m zp@jV3`Y-(~*$ljiAc^XnG3&k0(@KxP_m2maKxNXilgyt0QAN||3<-Tba-&8p_Lf<) zEGxEZ9}#pDKc6bo9;)@JS!#!y?G#-%qqBgcTZ{JGfQtuzQ?Vnc!M~Yq4Tw>C@(J#Yt3(he+byeAQQk zNbQ+CO<(yPN4>6Bp$i81p5cvKBq!zNm>am+mj(meR!uBtiD7~A=qm52H@_+O)ji~; z&mPzO2|BztcWlr35|jjjr!H=5sb>8CqqNLsBcqm6J22bLBBLTSordTo!S$?Q z;^9gdIATd7)9ZrsF2qSxu#Q6(%Lei76ZzITIz7nIecAh^>5l7jN0`XFrtA_X7KP2c z6tOx1uW7GBst*qk0F4E40?Z-e_$Lqp0!jS}Y&JGc&*^%lixrm`EXZwn3J3WICDu86 z_q)r>1KEtcRu%tli@Mke_>lR;?$5tov$b*k-LGo=`Nt?3a#JvAqN|mblkB4|lZ5To zKS=+OjVTAoKkJ6!6Jo^Y3i?Dk&wc$GljrR=Z}Zp) zL{%*~uhcA(9VM3MN>DnIH1OIFJZd2j#?<(ClhqfY(oQg@os0a9MLhNPUA{smx{aI&{sVKx#}JvFS5T**@Jma}QEhoN&LxSfU=GB4Ek_V_eK zrW!;O%KUMEm8kgT?c%_WEpuf=@eLSdHLXS1coy6tl!-Htt_czmx47fDy?7D*x{$&T~FX> z-?5Z1l-s;?Fcl6}@f-i$|LyN>U=S;)E+w}xg>MT%k*=@wzf-!Obk|`;T zkKf`WYP0P>4@TXMO?}pKivGT!%NMq)>DsRi#7I?Tf|d!LQ5UFmE<0l62D!l zCOT#++ei9S^)H+*8vlY9Ok1jby=o!TA#nO1>|LtWfa{P$13`x@zJaAicBWvzISigt znW&fgwM$zf-oMneR~{#p0e;^)kG)5=U-Vr!eO5B|l6aVVRI9xgwrfxItu~!~4>?zB zzLfu7GD#P?F%Ig$KG>Kl1C}Zzpc1)63Ecca>9^bB*im}!MD3Kv9z?nrzb`k|3ir0W z9II5__dSqlQ1)B0Vu!O&O$TM0jJ-onJFl}itT`XFT5TTC_#{>yvY5Lin?3bVO5FKM z2m?Su5X^e3$T1TPDabik;{c%}SL?t76`Z^~iqs-bS`f{g#qpDO^>NiTTK1%kuY=(u zjy7kKTT-t|7HlhbsZ@+7KRh3{e{GOB-=T>633_{R`Np2(g4spq$2v!ncOiOSH)3dE zt^lLyi0QTmVp!l)>cs6Se~qR^{;GFO0}^JwF?=2bVLT#qZ@D@cp0LRLgOZ|~#`6PS z%fmLktiuxT_Sxl1Nrh-OkHr{gQkWtD2s-h#fQvq$_)qycB{0vC2ZQc;^lLcFBBYt) zwd~8>fj?Z_(yZDn=vtZ)pSZgs9%H`SN58VC_TxBESERaUI|gfC*Y=JT?Va<;DCqHE zNR=e8d_j>RD@YX_u3O;appaq!Z4|kGe4MlIiTc&X*bcr=1WR7!;m$pd1@@010gd&- z{hxq@96Bo0iSOSDvWJ8XABzC0J)g{_%CyLy#L?*S!1sYuM9ltY>4btvk%{uXS<9Ro zOC-q^ct6LP&-8=?Zm&1*w3MK4z!8{cLjRg*S$ZlEa2Xiep?ieb!A$DR3t`iqCzeU? zC#Q|B{RPL>t%ggHyTTpjFuq@$l?$oc@Xju(bO~WOv?!>8&npqT7&Ha!16BB=*uM{x zL#*B4FX^WvwYHKFBJYW$ISD>>4=??Vj$D7}3ws(5XNR(pf{lPB2mkA>W1)ed#$eP& zhR!J+Of{;8N;SPB`Ky0gdr&4-^X7HUGV?5Rb?#Z$7QY`@mQ#ZQx(4*kF?g8<)7cX6 z0Lm4krUDwR8|0E)ak6MmSjEIJ#bd*#o88kgj3fH-3l8a0LYY2v=2Dkg+iALkhe z(F_>QDy%cwKlNz$uv)d-0$*GYbdUk*fDdfUt`xF=SO)Jw^#@baxi;*!L2MO?@&mn-Ddh!igSet-}BC@@P!!V@$=G zmB>fC+7$mY4v(El8*mCD$SW$ zF`dd#NxgF}|W4SSe!gzeZ(3-0*I+gSqSEYeI~>a~=uz9b40(TthfyIoVzUu z6M-ud-}9Awg+QlCd#ZfHm)o-XTX$~up;M#2@=D0S)Vi(F8xscv2}R8Jiz+6_k0?SC z(#7A8QNc!KN>VF-M;XapgJAC`h*&fS+HG$EvU2gOwS3Q!0*2(qxboOOI&mTZx@$^#BX#DLWwHNL#o%!!{5d zJ=RdZY4)T&2n>w{z!3_0aQ^Q(dzr)9*Y{5v3ipxKA;mE~&)S9RqCuKjTe*{t0Efj>}ZAOp^e?-&H-6k1vzalCaU<8Qnvn^4> zZYyo(h4wr~CZ%FgGCQMn3NE;RMe7YYpXjbb70|$ z`of)%+u9~Nxqpj~&b8wgNMfl>OW(Gr#9bq5*fboSo~d=sd49AONm9-KnB&7eEw0y$ zQ7@u7Gb5@yQ{As-6<)iAK;~vtSs{#YtG7rTeoI7C`brS+hO;-~hXlBrU%Ya|l6E=q< zs5SpXrDM8al4Db?3Y$G1@%QzYx@%6tkNmV%wL-4`9$gY19Qj=xInx)YhO`?5IPXdO zH|qUDR#OJi?mhr>(IM%2_r_n!dT z5nAO!)H#3K4U%Ik!yW>Xlmn#g#dY%lj2t{i!7wf|-zRRrwZ9n1C|!##fZ%*0P^4gL zbtZ82kIxtx*7T9-Cks(#IL;Rsj@{D31}ZdKuqZx5P-}zMV<#E{K_e5e=JjHePz<;m z!7wHLR!6?i4-E@n30|$95Nyx$m=(vV!$c;H#;5jP+OFQD{a-=StOBD@uw{S;VkF__ zvi6?oI%=~N)!H33dumTT!F&3)9`(E;oaWSDu=b#c!Cx5D+(=1$$Yr~@cdX@S<-XhI zjR)v@bufgQT*cdnHdT|e(vq7e%(r}C3Y)D{gB+(`zqdnLjYFaVuJn_%g^_iA7PM&n~9S@6*hm|$ybLTbx&_VRK|ZT zawOt^+$4iU1w>fspN`Ic7K``XH(#Y2WQKjm!Jo7)T{_qS!2Y{=IlhH^(XS7CjCG

{>4JxqphmOM8;q2neGk~Dj`(O!H>>;EdE`@(x!z5WA7(4 zDpep662KADJ8006AYX)H`PQC|{tY`E4j9&=He@Y#ey&T(Ts+ZGS0H^H*YhaW>F@Zp z$2A`~DA3EHd$&t_o^Rtg-n9a6x=d9mc%4|yI(?`vi1Zv>p6fP15Cc{grC%Mb)lr}- zrKdX5SXq%#ra4iyw6`-&eEQ~oM=_UEZHS)fSWT}@E0l<#qDOXwhodW)B6-N{{k`6&&kyJ6JQs>R;n@58N4tz0kj|Cc z4ypuQCp!^0uqh8G0R_%a_9x}Li8$aG(Ed(yQ+RR~HxcnF7}lBDz7OUPk0`238I{%| z*8k}KdflVUga0IJc6sz!9h)+-0MTtJaI0aXhv7M?{8XJADykD9qc za;U2_ETw7Noen|Y|06&5;Dh>p;&OGx`8y1$>)`Q3s7uN#9ie!ji!t2&JD5-tDyx13=gioUYGjJ0(O4;8kuB=#?O80 zv*KpV2kFLMQ^oj>Y?lCYJ`Y3k7tEPSQ00`?i_*cfbCC-#^uAZ>3C6i`YgOz!k-}Fg2HNY zKOrC_2pzUTr|H1qu#{cl&Utj?UcWYBKW{bsURoY>3G35KGXl2+V4r#2Z=&vcOo{}F zZor9TC+AUCf9oGNJbJjF2?l{oO2l~0LBBHNAT2WH>5)hit}m!Herg0aP=i(wqf4zY zG=Vjd<}o#G-7Ch+Lk&WqA36SCDHc6wR57j7rVdVBNOI9^+W#U$Jb>3s5b2#e1FpY{ z*?)BO?vF>1RZx*%fSnrJPK|*1R}qm=j(jSX+aFe^$4py3yV>jJeHy~`(%I5$#XfpY1F@7K(Vqp$0;Y?C0o=zCbJPD#Tt}oLV6bs@+zuuhabT$6 zcPH$gZ~A4G4^Q(w5#?}Gm^5ur>)y5(3htc%`&R0r;N)l97w_0^wSY z%X;lb%W_`{us+UCJpTuv znyZXDb&>)s^Oe0ITqiPL(aE%Taz07wDyUMM$B6NEI|>KZTAcm@)OIUWUXZgKl8@J zG^PVo$o<7}E7nS;0`=!Z=v*6YqBo>-{yW}eC^z8nr_rl8uZ_qgl5dUi35k;34S|;0 z2Y!MSIY;5-nsq~j zK|<){u>Be&$t)$y-qniqzVbY5vs4G-Fs(QHW=e7c{7Pw`~pxExORXU8OC zNQN2o_r>@G&eX5bHtf>CaGnT^U?uNE4@=f8Ja`4JsbIXHg@6yUH4`g~r>-j8U$8Xi z%zzuPHl3cAYuD1<kOsfBfT_tI;`J#Y&^R+As92vUHBpabFlh92e0 z@-r1<>-f(R_4nwlmM8re!@I2LA!DWskx`ATCMV`w$Gl^t+25WSY@`zq8$r z1+}>Z{Os4*MQH@agwOvlb~fX>$LbSt)y;)+K`w(Ti+iSY{In_2m&JE&!a++jD5$aa2_9shY?sh5cvU`yzs`qJp2$0+l;0ls2!c{joqxR3X{R3Jz;)ODi*Su9Ic~7THyw)tVqJU9Q{d~ig=E$BBf`>-g&kNU1IF+qCB*h+177`dFd zeP)B?yYzXM-?;OADDSGcr<}R`;(OD%qYo8HWYvH9WK>;CDS(7gV8WMPJah@bwEhH&16N^#4^ixifqc6bVj(5Nj&+4&jxT$g z93}dH_dsK097RCB1IY)Hjt87kZm`Y8@apI;EFsZX!Gt$&6k=X@AP0%@8SPXEtmQw? zHMZ({@pN!&T6HcL`7{)z-T<1hz;4>*{$+p>%ANnpty*g_4R%`Z;ipE40R&eJt{XX- zrXyuRreVB>&b500lkm^)9#c8Sc8Qg6vI8WV-dqZ$tjq!=1ors>esBuPIyCSfWjSr9 z-4Q#U_CQP;ymx+hF`l^q)B^6UemIXsf;(n`*y*%LqT?-qr(s&9D? zJ=+J;L*Q#?vs`K*RK<#K?+Y6)e~kdyJ6;x~6?!)%bp)Jau%Z)+4mfZ>4Cqd5HLrmK+06KOHza ze87u7fH~#Pk3a=o(T!;;KnqH<g#d%e5>8p1J5;=j5K;Qs`SyfH(T2P}xhuFlT{f$}>KjO0>-xGE zx{(>j%f`$NZOcK32&GMqFClJANrk3oy-88(O@i+n)p6ohkT+5# z9%Nz`-L4-L({Fa(-#k@U564Q|%Ah`u3tT?erg5^KbWW)%vD!4Q-y(HA6w62x-T$~H z#~FA}M1nSp!^G1?;3+y72;Ksl5TAp7#^{8z?6}yDiP`Olo_6 z2>b`6f^cps#pD1MT{qb@gYg zz+;P+c}xOj24LztKrJAXWx+j&98{Ty4s`4FX=hB4Q}(mp3aZ!^)PCy#7OdTk#gfH_ zIl$?{`78S&N_;K7(3nO^BNcPh?lyG|Ed}7-dk`cAua7Cg?ha{wH)`>Dx;_b$UgW}8VQK`+GW2{ALWuv+AT8{A?^#;0U36ijG@H^Jv<|tOB^#Nl%-R1lb;O5 zJl)Y#BPTtzWix_S-WnS2O%t&C{#qjL+P2#U53zs-^F7O#5%UIBNu`gCB0f_#0bJT!=PEck8;le}_ z(>=2Ky>K-0joVy9O;aW_->&_}gL+msp!;9kI-4yNp_)wI^AOzEKO4PC>1WI6Tg_pd z67N#kwZ{MK#Evms=GpU|qeLq;O8Y4hM}nz)wxVV~#s#1mZgE$0Wq8e{7j#T8O|Vjf z)E<98B_X4Tp^Y+_{+fSbB5Ta#MaQ@Md6vBD9wJF;Gj;4yw;%wCKP%Aj?CGBFszxHa z<4SJO6C1$bp2+opQ!Hsb%pX|)B?p7*>$iFtgZhN0+a^ncy5pv^S#Y z&SHmO17V*j7?{lu{MNwK+^BfleAASvdh5~h`fnV4c?KvkIkV@|(eUR$^)0SY=fKuk zn0(tulu@xurIM=ioyc(Gi)#eUgP_+3i_^>6b_9y00sLt=vGc6Gm$0tav*)Xa+EO;g zVjf|aQ~YoO4i=^;@{wD27f0orIq2E1%dVMbOmk1Erl%UM0J@O-4IVZRFbvzkJ7s=k zrDP;YqZL&uRb;FZI^}gopE_{;jRq6_Znh4$9!pIj$u@A0sZe5`*1?zTb8rAd1(<_^ zxJ-^LZx~5i;$#XgprF(Y|J)KVeylg0UU~RvB6hXNb|Sp_bf$bROU7Van*I$d<%8E# ze<3k?SS!|HsO}#&!6zjBVsLjZB}^jnfTd<5KTfX^w*UgT1*qVf!k|51%tmu6Mj?9F z>2P-g4t*)MHNM$8=xJ||M{V-rkMy#$KFO41q<8ng#36iXtEhCW!5^=g_F4NIppiR3 zL{u|sS#%JfV=Mi#MVsaH$cD07yEW}O7Xw^{Kz~daoEWIsh^#^8<3t-tZXp6(oGK6@ ztF=cQ%-Y=EUN&9^>uE>QN2qsE0xFG1S=Jyu!<>cXD}{eUp;|vv#zl^?S)SV3e`#*oPb`ijcv@R1i3T+FnXl!Ffh2xwou0aV8UHxXVamLzM%s zoIUu$FH3Y^lGESB1X^g~oAdX)GHkR8O40u6LQPG@=}-o^61W@TWwg;RVNfohU~Kes z6xY$nyU;L)ya^DfGIGXtt;}zy1k&ExJ`N;aEKU`>FG4J&%~JMM5jOgphw1nl%nb4J zVLb+9K5^%(hPP?_t5f7%Ea2)WGVEYmGC2&NGRYliMxD8en|HZhWwe%h8D%Wi3UaP3 z|6uKl*rdF8|0a+R4f+*_@=wv5iyq*bj@i$tH$_9l9szyq56<{6?K?vXMbJ^ccpSFj zdH0g)+9&F2F$Jp`O%&s7AJgKc3y2*w8s145d+3b*FC5kYa9EH-Q3iCl{ICx9k za`)&Ylr}$@YJAm=C7zOmnA2j_PreedI@FrNto~L;FI67fPQ190>#j<$DoYYfqR`B7 z6jJa)?^+BKB)4jX&|d_U3YAtZ6(avE1gdr~|MWOfo80Bk_tw%?iL>=Y`)k>8BtrPS zlU*wnXzxwesDKIGsJx@yU0wT@eYeAJk$ugeY6-)q`ZRiqz+cvI7egd5G<%E6l6*JuX@2@pN@w(+eA~V3Y#DO_JWVhq)FMUyisQ?DMCm<##(?bf+)w=Ra`NMIY2#~WBi+!Z{z0P zrywjBzylBiY4j{Q?0{}C?AJAt++e19)_y4Lpj-m7Nb+ZkzJofwhwzV|)JSc=X(R3n={gkoyo-F6{+jMZ}2XWrr*?5^> zYN_6rAi&G^0?E7C`}`{-$k7T|S~6`D@?7HeGH~IuRraVHwB6&V#FT zLu&zeL=j#yrI&F&zKO>@>{e=jLXn~i3ZfDY1;g*gfqhj8Hq-6!!Q-uInhw79=;l#M zqOya#GN1od5Rwm_%e+Sh{$|V_zM3+LxdB6pW17z~fBmPA0TYC2PRD8nNE(T6oOCns ze}d?r*Oo_}l>EqOd^XAdhy8TD;zdNVd{@QEQ<8*U5t`Yn!|s8RvA$HB&!=L%P3mzU z42lz&iM*PQ2M3xXt^q9yw^`XHlBt0gi3R-$Vpvm^8ljmn4HrN1N>$`S>K_YqYJ&c z$!t3^XB4;UzVRRQslC^uquw&RwIfH^Q((tKp;=(Ux2|6#fTO4C)$)2rG3)V2D zUY<~zaH%xb+!>AF-j~&rw83?h5AW~OTm-sr69G{Ja}J)-X3xeRgn0y)1gTHaHo0Xmz50{`+VjIM^k`<x zZ_JAed{e?>ZOHSk*Xd(lFtcGeVeKmvoYgM)kFvz@7xELZ@pV9rAfddF%9hy{_JD!? z`{5fIx1fywZwndMh2_LvUwGExVei_vYxu{LqCCn-Y7a1qWR? z+5vaYOO%viZGSv>Iay?SB7GYc zix_kx#9Hr*-KPHjw*KiuXqg@5q|6zk_SBrx75Ji%sC#@5StV>RlNBC|I}XI-%mQwe zVf&AP{mJo(70Qz!aivr|;D*QX$H6S2`80UW+Yqg#g6zZR>X(KCM4~OzfCNEpz2nr9 zK5=iXbIOa2jUH}WJp%2I%eL3-)i{8RnXhsq2>m&6sU$$sSuWE7rd((U@fzP2T@gcr4|w1eLlomEt1-=KY>sx;{&2|y8U3X^d$VHyrI#^H|L8S> z6TAa6?pr@?C;L9qp47?6;6m^J5u0jpp6siAAl;Bw1KvE4kF$TK>8hfjbVt|_#vhI! zD;r#SRF&Vb^n5V<+=23ToB#}*C@MIgWm;O2aGjy$YzMmoMj_}&OwNw{0czC=6~LZ) zx%6+2N>VLy`ZH2K1dB#&D&>lSJ~kYpd!`?>dt#)#8N#-&skHg~2?XbrYZd`uJ9lW| zE=YmI&{%}WKb|H!wCxoyR5>VJG5XRG96z6{`$TzfU=2_&{~3=^8knN09$1IcM@_B! zse>bY+cXywRfirCpKV{e2#5kb?Ee!|-%$W6S||{$H3m0-=2-w777_^=G}Yr_2YR_I z{ls0@b=}wPw#KB`ac4qEL4F&Axa0<5R)N7aC4&X1V4K$ErbSlV9y@vTONaMqB!QCb ztWMCSe>Ri8)^(c0P@PfqZdxoBqjR@vMv2bv@l|WXjsMWD@^m9^?5=KKT4w`n2QJ(v527Jm;6=hoZfOM>t0{m`FlU^u;>^wLyx4 zz?2^)7F({q(({_Q_GEN2p*&*CtS7&CX1_EMoAywveD&X>v4-Qm$4}qZvFynL(*^l% z{$kt`-(Y{aWV;#3|JTqE!T34WVs-dfSq$m!yYaE&jj_LdiSN7m`*D#{+%kqss5c{O#G4)$3c^b6$^rm;Ky%TeWBD1--Cs3%N3Z?MyK;iQTQH0S zcypnp|4qNS;}UAjGk^*5Fkly?jV43Rbb*&rhS1p$%-Sr(vp9B-zk0xlPQQIN&AmvQIQeYagz`` zkd3zZaqzieV21fW>}C!Q2235wdm`A?|CP9GzXCWUzsf=@KMi;`_5GuW%jSU`B9#%} z#X27gLLR(M{qrh~Qnvli58I5gnp&UjbJ$!(+Fv%h@`qL-(3^tLjQX#q!OBO$zZcxW z8t{XC{;Ps;7_6un4kr-2%~R(mR?^NA+IV z6wCfN9)Hw<)XQ)jG|{Atd4E>d-%gG5B=8Z~rK0cw#%xZ+D>&mHP^{C&kCivc!09N! zYnw>{+vKR1k%<*B`8P9H{F|8x!B@s|vR?v{8k9-{D;Z6ZM^HrHGGqpjiw;+wOUL{aFolJ2P$d z__O`6sXP-|XL_@UsZ@2D`el>b;`NMDLadq2%EvmYYS6k3!95mY98Ng|wPQkfkqitG z3V3)jyoMmVCSXgtr(WJ0YXekKUvRJV=0@Rtvi9yk3=YZO;|rtt1gu{=g};XwVH8Ml zy-nCeeWL|?7;vF)5CPIPy9YLD(Nosg)CEY_)Es8Fzcbl~rq)8wUcsvtdBqwAfxMjr zW(IpgF9ZV?e-&U$yW|Sh!@i2Kqg$t6+9>6h$Su8d0nSJNph4ViGWw=M>gA|JI$j;U zIZ2}4?jvey!#J8~K*JAi#QU_=PmLgP+D(&856JJt+0t;vQK>(c{Qp+95X^F0Y%DEN zT#fsO-d4^ur8A&)G>DB@WgUK05tE7jSAQIL-Ta*<8Q%BDPK3{Ysjiu`RblwVFwN>; zwrvp<9XjPVwjkFaVeAyU(UUkLc0II$viit=KFJc$lyP^+dRU3&rkNnS_DKnx(2S0+ zbzox;vxkfTZ#8()bGwLOWM=@t`;N))@_h^rw|Pi)y)s-{BR2tCR)_|^DIIZ^q zCl||g%L^?Oum{?35(Hu#KvV>bLtg_G)OD1CacF3j)4HZSTx}Jl7zwW|Q$Dr;PcYX&3iFS_GK=CZ+{H&k1`n zXMjEUKW76g4(HYk*VhAu*RdHKQTWo$^VeuryO6t|azXJR$gyp>@sHD(5sgy~8Mcat z#fJ%A(+SlfY;SLmG2pN9U}m672m?~Lo2&8aw_xn zua`#P)J`Cci4C~Zry1Po6LlB-4g;c!mbMVTWRlfwyT$aKSSG>FH`3d@HUl*3_A7zp z_Y7K%#;eD}eJI*3OljnZn7dl#I{Q!u6vrM931SlXp(l6c0VoG&8bSNVO(6}=4ayR^ zJc=rutG=*Xm*qMXr`Pq0c3@+C3Ov;7Q|q@*Z;w`w3r+qs+p&`8jclwyvlnIr7q7+a z-v4}wf>iKE0^ukJ$TFA|H+dTbwQA<79u{ZJ+|UC|qxDBpaMb=+#B)A+m z;stWM79I4;8*Xq@Cdo!NK>y#q(i_Zsn-vv%0BHM=4j#aIDg(6)P7IsU7A*wYS4D{S zpX4<$_CSqMr=2d%^pxY50sp7Hw|-zoyFRlv~qI^DYQCiqnggf;r7T4ZZ{ish5{w za>33yds-*DXT_%6Mnvx;@mjfW?l@84;LmzEjdJ^_eHyz`e~I4RfZ+_v9DqHs(xNNC znmcd;c-6k>o2h2`oyT|C9ab6LuwSbU4hXYMa3{4|Exx>cO##Vno;DQo z)m@!R;3F^ZP8HZI*S_+Qf8ee&w;GnG>oCPHdoS~RqAZ2*$<5$t7Flm-!t~9m`kjS` z>Fz*!H$m^e47>B-!(_qvJ3qvV{>9(uJ$3`WegEag>(DlvTy{PL&K7q4XiY0Qi_v<_L-@J`OeWslTe6P6Gl8T_r^I<&$>4zh zY8w=pX*f|V=ZBJck$}KQ8Z_+s#;s-cFfBY#L;k!z4{&xzhZ*0kn)4YUf_+o93QK~% zJPR&KFhKHAHCcK+bg;TqaQ`ja3zlg*xz8Y#v=}AjV;{C8uK1%0CuUBrLWM^Mf3h?&z1C=-L zh59G$9iW30AK@BZrE-wWpFeYS!M;yNKNbnuz>HY?p3Tu0f_KuP_(;f;Y*nxIxj2`E`=|B9|Mbb|qdx&U6Q^^#%Z zGD;g(PTpMo((&mSKEVGuhbMOL^Sl@+*RNiks{Tw8BY5-}PkSUfaoF4Xs@Y3Sbuk#g z`H0y8Hf;LU1*^!0!kLD54Pj{*2inf1f^Et-?M7dq_ z09Wpw4m6@u5HXXE?W4=1{^DKUdcw* zei4$8b83W9#M!ASIvxu)0;VjGbw-v*lrTOU!-(Bsf&o+~Cb4fBL}2EYHT>G&@+F>a zO?q+Ktn(&74WOdc2Dk3w4sV*c3QWmNfYct_o(Xc`43Aa+74z{^9KBEHm`{(TixEu# zPt5{2-iQzWTTafK&lWzd$WtO_ATSzWTkkjkB>T7AGt$M?q@!bJ}-TO14NZxk*#FA)CYEa6I)sTRh<;aQC$S!iEaM1 z;K*X?gyN%TvGf$$TEzRq!ZNpbGCkN)kyG+C``?~s#tNRf3Q+7DJ%d~ASV~5Q-bdJ( z1ms;o{B|7g&*Qm7PQaW=i7_W(JiK>Er)8C@^Qd*`?(tA3%$3D0s4t!02m?#zC-(&7 zzMwapo`FeV!J3vx5O`6uH>gWxsZ+F4&qBj&16Xd}sDZUVfX*mhos;u6CICLezYpTU z>m+o5YlrJ-M#1TE{?+@|37KVaw@nT{d_N08R%YTf-3`gE7i3_-27zoN*re3#XuQgB zcYM#z_Kg=JAchZek1@KswVkXyXoYz3ru0p*a|IyHeni5bVJWhj?qO#$?02H-(1;`Y zh^T>j(PatVD1CpCb?Uj~DJO*|5g>{!x#ONc--(M`o(w!<()k z74bH|Z`DRsyKqHhF_r+Ow%D%sfOzU8c>!uNzU3a@|nc)$Qu+DJ9F!uyl$!b>X#k`Y3p_Ior%Nk@kCvj#Z_0+jP>Z4#f^iH3@K$a zJi_fAuSJ^Pl*}m|=sAw{wv$~XhK^UwVrQ2J^oll8Hk;?11Bk*gPme2D6FcxhF8o&J z_9N-)&#MW5_Q)2YIAiMC%&PM(|4qUdmU0IAda{dl+)DYY{!9AJS&w(Os=2+>eN8h+ zB_OtzQRM`_tl8&PjxKNZ`yDAi%VVR!@;VO`Cfev2g4!|Om==`MnDHv@Gj9WLQ|b#H zu+UQwi{%+lF?!=fvd{5XL5RX;R&{K2#cU_~kdI;t4$`gjcVRiCC2m~oO&FX|pPeBU`zfP}8=0cX2G;~&d>>7CkX*B7GF-hSIdKd8x zj&FIT^g|0&KG#UI;v>KOM|e=HGEq=t$tBj3SN7CW-~ibofRDWz_{@*U-iC^SbG^B$E{tYL6<^u!1-1v@`=D;mubL{(%DXVUPg9V5QzWIQz7ds;y%4hLi8iQi ziezrUZ#z!C7=`OnVQ&`lLvGfNeKG|OI~d38HvK^l~v z+fD8Bh!|-z7fO_UXHMwPXic9wvo7INTpYZ;^|U5O4OX5~;Cbm?;VFVt=ZUvqo}Kb_VVDuJo%`0xLR-*-_nips)MMUIb5ukfTd< zu2N7J=W#kh?-D!|%LBucGK~&jU!N(nzOH1d8~|RBEc6=`%SjcrftoUWh;EjPz2S?L@SJEIh-!b86Zh`bGm5Act4r=H z)k%UR)u5o%+_7fg{>%JjR7!qh^wRy6dFPdDdr^zI)CrYj0?J^m&pscGxH|UHUNzLb z=U6+>NbEDmc^8Xz_)HlJw1s`*f26&4^uRS(MS82K`aUj_1JfBkTJhz+?8tM5W#e+s zqG$s1pYD?{9i|}>KhFT^6jE0 zS)9+hQ1GR8uC+n*iw`^+ZC_$U9$=Gvlawhv2F!d`+Gjy6uKfmifarZlXUTj7K0@{^ z{#CUkRTd1>Fz^P71POs;=^y<~S~JjyA^8YBbckLuK}*H@D+}cXb2Lcvs!964hh6Sf z#N~~{Tyi3>Lpr3VkbWDEX3gGW!?rAYbuU* zd>{DksTF&mava!xvV$mulIY|9pAI7V(tN}>Db)n)<7ruBl6|~ zvfWLGxAsUyEVkIfz6Y;fB{%q5_Z*~c*AvKLGWd!K;{H&pI^omSPl6UEB?6Swu}8alsIzj=(83I;bfIQ^ua8L)k`vaPUxBaC3 zwZS7YAv)Px_m{X75Kiez=M+ap?(RG0_0{A{$K_dQxgU;?ik+QcPVG~ij>Rw&W5ug3 zki`&W5k)kG=O@~k)GgM&YzntCLCE220^HBm`sq$RiR(Y*1%PR9v_`g~!#*L}89f?@ z&edDtmYOA?9^Nk zn7qKIZ89lV8S-M3WY>Yq&DzFr*>vI@(@TG=wtUt{6x3i1T}^t~EEp{xh(=GdqlTwV zM`m&BS3*Ar3vTZ<%M_(J6nBrUWACW^;d>s>1ip=r|WuyUU#Zvg`t=HoztwK4kU z*Tc%$&Zb`dh0^w`S1%#@I-K{Z?(^+#`!d8L6Og~+3Yt0P~jY7?2D^u`O;3FX4{ zJS~6>rt^*5ZIE6)4lbp_@38Q^2)6X<0}H|o_7p_1b#1>5(v*k~ZGwY1>cVSbmp>#=2N;VN{K&{Lb@pOXBPw_V@!&S0d-^$I zWVE|sOh3bMV>P;NtNOv3u@pSzv~+<TOt#U`F^uVA@{{8h<#$y{FlR{+`_XI z5bf(fRUZFZO#4pzh*pEh;_J03b+c)^5x8}IW86Z$1N*&z)G>JJk6f>b7Re3Gfq+w$ z^y7OI)OPdQ<=o|Q94MT??og=XbkmekBz$X-pUdHKQYY9>3G3H;Ds9%CQH+W~+TXzh z+r`gafoyQ~1yf>03eRv-Wt9+M=t}r^l9`T!)*T6~{Z?_pj}eL&9(==nM{l2(dd;nb z8Q`y&1G|HWak)g|N#eyBsA7vogaPw$$?=P))>$fYqrd8!I$%w}6*E}LH zFYP$d2qL_3E_6}+DwW?@aDb}IGS~P$K5J|I;9)0HLJmWH)bSv=>X7yF>#%{z_H!)M zcmy((gIw;5Iy`~^MeENt`vpG3l>;`B=R3ht>H}f8CpFX&zTSFqSLa*^$XZZW3hhS9 z-JF117vb*pe`^i43!ZPHo#+(_Duf$7qSvNvJYVY$s2bKcJ%t)>4SL$q+@lj=1KI4( z_nQ6nRgySNjPO0Y!ewct2eN;jX-d!vtWu>LWnzn^?u2tPy}&1pq6u0eUji+YotrCroSGSgDAv=3 z1f0*_lF2{E4Nqcs=+Z`Vg|sqpDR&?p$Uv0IDof74V9ZZ*vyWBbEbDFAEO}TtZBNA} zxhG*-oW3$x@*Z|17e3nsOh-@A8=~$oEu!u!V6Jyry_cOkdRSq-05O=^dDoSL8|8S? z=o~PmoK}4Aq1@JdawQ?yj4S7z{h5tHEr7UOOZa-X^T7!yK+!+G19B7QirD-{Hr_fe zrL5e(i%B?@4_=^bp*&S9iQw8N%l1V@?2pK}Nq!ekivtia<2{jRgo(>q1c=d)L%s)M zrOib_P3xuXc~{^>m+w5q;}%B(5N4zy17Swt774#%-ln~B0^YKlzK5le+^?b0z~CXMpFGYfG9 zD3PL)%=(@0Bc65>^Jl&FEwecU;l+rd6xIb@Ux<{RXgA1>BJa10d_so+SUv#ug8-yB zOm2Cs#+@D{zAAJU^PF(3xzsp2u+Ja0kGZpKSL6WUBw)D`uJVl<#L^i?7kNF?hBq?8 z@&h0HTrK)eLZ+~ckGCP7E{;^E#(m+tJJ|445fWkoc2Vf7D!_d8IOq_7AMHyRO!j$@ zuBSfmc3qPA*dibnb3q_dt-ddQaC>$S1ld4^VHpFUF%F!4^SR`>?jz}Z^22RBTv8ZX zjw2?z-sN>rS$x;3ThqGe(}-vMVMWMZ-){M)$`n>ypmqwH^;xCrqQ z3ph73DntA8Zi^R?Zr+ctfPg?2yNv62$Oy_zo{gf3+_w4)nOX=U3$a7U{AA3aus){b zKAqGMz$?W%2u32jFThL(j{uR4Hua~}x-IK=*S<^F9!r4ML^31S*+3>rr9i*qh!s+i zcg78J3&w0eMB0Bz0s?EbeYghqy3KR{0ln4oa)M3aU}0uyy?Ey z4gDx!yx`1?2Q&Y?i^V6szZcJu;IVL7QAfcj$An$^uPfZZRNE&8o${sLXSNIXm?BIYZnauKe0;VcV8ZLQ`EzBJ7TRLE>bYJ$4<@=4qQdcV@x_=i;==>t5_ zbh)_CdwEp%)V{1%r?T6^NSllFOem%m*jGE!0U6SMU|(I{ZFgD*9R*qa>B8BUzaL>({+ z)fTz3m86)0*_RrGTqxnoS=!DwmrX}mYTvg>k+jsPJ&Z{f1tOC)rXK_M4#SBY(DM9~C&N)Z? z3PgSjsPhwnDst$q-`mGC$5tCYJv1%~yuMk_NpNj6N)a6n7&64117Dj!pG_x!?h1E5 zbFEu62s~gKPjU%?F;by+&BWflfhI(r;bK#$B%Rz3REF7FYW8)5HE*I~+1crVPwoT~ z1Wd0~ub&0=|0svmUlf~w4!+x=vPpdd?da4q4knXPuLNSi!OWZpPOj_hz7mdsmx%QJRWQX37Lc!&IFe!53f(sh&~Yq8sG55 z*?!Vz(XfAU8e)$?_YSDH_x;6;`f`0@#%ozryk`> zD-nS23T}ovseO#7g zPLH8Tkt61^i&q^5Rdv0k!`t_#kz)(N0MxnS*)zDOTMB>A{caQ3*7y&n6#FEt+u#q2%%qI6M;h;(mZ z=(V}5(7l5~@1kky#^tf3{ts$C*;~U8PKM$8X00-rGlw&(Izd;;a1pW*1nJaYctMJC zWzHExKn)L`eet?;-3ts&>+K*r*Wi22_o{-`{Mfa$DygPOc}yv%^e}+G7GQh9 zIDKZr{6wEJu2p_d0nzuE|8ld5I@uI5KSZkJi0UrO{>DZ_OkG*lY7f1e5JkKZKnBvs z#y<-jS-X+f0NxhNI1y?I)*Ov?9abnvA+?@zwz|lV30VH;;$3FvmP-0wOf;#_ z6Fyp3rI7;VT@db9AEu9skt6Cst^6G=4p1O}tY;K3Io$N~P}egrJ^^$)<^g7bKqHr=j}XKnn=pu?I9~&$)ssOo*OR<7pi=^ zu(DFL`+BP}{SKEg{&@ox98s0J!15h_YS)#yjADc(CY0QCZcaJme}Zmkcrp`QVGBGT z*A(>2-%p7lX*UYdg9GDpHO;W#*aGjrY-^fW-EP*3c%T1(5vnvK9~}w+amW0IzoxDf zqQanEvqmN7f{9l?bBrqYm+UtK@0UPxB~8rLt*%OML$S=EwjO&d3_r*)io z3|y7gcM9Hk86bJ%FqNA)0^Jg-DD4LGunwq3+WO#Q|D^!4OH-klnNe7(Tr4-(1VeH43gddP6#El|nVV05V`%*B>jz$Q!nBe!P=lg=jF7 zglzFCK$cNZP&x!~esz|&1w(IHdpCbSY`wViYaGDRJr6Goo4lwy)m-^mGU*vw0iyh} zW=_Shg}UT%Sf@(-V;$p({cXcJ%j`F9eL-LQB3lLON(24pBbZpCg&8Ac@WXJd4CYA? z8=QRP$@K@PfE;(9CngbI`grH;4dhNe?wfO^=M8Cci#$c8zMvB+AH_9?Rlh$|!D1d!b0Q^U(^tPk=w(e>?2a=EqWv)7j+y8;P5_rq^^C4rQMh2GOPe)9*o`yMxy(ieCnLebnLRko z$KL=z1bImBarOmtBjhOnjiYb;=x4vA0C8wdTSF}$O&8`Jaa)ih8_n?)%th)4D`<`2 z;N87RMNlcY^&|%>w@ey@(E!AgzmzEyKC&<$O24DS1myD&Cy1><9 zw7WYw9nwA&0waJ~!@kM>88`ejpyc(|U{`M`g^@SQ%GVS!k_KE3&hrp`Fcr*w@$%`9 z8qzxdHUe`GPGpR0-g`}Yl`sDDU4$Ea5}8;oq(PhIw4VctAn~^0_^~qZ!nZi}B2V1* zOb*qfUZTshOKytpw$uGgDeSVe@DnJ5g zIxP-lWv~P4o*IOw{)!d|EP**BrEt7oayv8KuyGDxKZFc^FncZUzTD1#T-T?+U=TF& z1Q2Na84R#;a0)Aa8PlUI1=}*)hfFAMMKE0uN*BM@%It-1c)j97N&S2I21mDxt`g4R zVAjq6&GZ$r_WHsHO}sb$q+e^|la^%C|4|7`_{>zDF-0)aTGrwszR|f!N{y*Hl+1tkK;T-a=gm8*F*yDs?dSS2=cXVL zJ$#Yv=i9}MY)+}lnY_W5Uj69;W(3~8lUa4ho^YBNkVq>U!aJmleA&BliwVkquPVpa z0@A^aPStm~p^}r$0eD_^&7agOsl-l!Xg^sh_KTz zhE$^cmo7I?;FN#!r2i*DyyMz@)K7o1A9_19!EBf!E|=e9OpoXVZ{tfl#OTiJSknHp>LYfApcUQTGaZUfU`3DlT8JOq`fOw|m4s~D# z$t2uOzAg5jO9(-?%o1KO>(CNoLf2FtFoD{Q6nXXk8VQOg`HxKV-Q)3OrGkAmZ4V)U z(E0edsy`jkfI#VRgbb4Gv;{EaJrNH&n}jz{m2E$R=+n1ip1ObLIHn~Do3y9Rc_8}s zYBNa6;-l(h77FIeU8n22fuCS`lfLo)=r<*B2+M*u!`goIAXK&R@i@Y>HoP2aShCnC z<$t;%4>veqw#dFu3}b<=L6X_*XppY6<{x^ON`L-0guphoA`= z9UIiQz^*ucJK0Z0sHB_d-yy$Cq7z-_xu|Z^;I_kD3TWYh1S}*(Kb(X)cA>Yp`WMae zS)gstyr$3oBQ6A|QkIcdVAe=U+hAAYnq}e%l$RK=eb!d*D`*Y0Z>-$HE@RmtR%X-hQEQ*D+)!IMq#<3>! z0rhbnWi_)}&wTi8BblBU(^RFZ_7b0`OqI${PW{m4NkpTnHtG8OUI4jtI z_IhB(*3BETQ2SICB?EH|_SqT^GLxpwv{w8-Z<&X0i5{8-_<_Sx!Z5eM=IWKGo%|J; z7F*WB9=|V?ETrcj>zt(t>z|6U4vsa#cG%%@?$O8zabX%I;WlNh8|o#%Nb%k%f7-TLT<EiwUJ6@N`^rS*`LLjyRPm?eQ75NjYGQ)rM*c&cH&^ z&vgZK_@BySPeBR{$$VX@vXol{P96Yyv_C@)w6OBl zUe?3!^P4<^*Ac>}wDEL204}NU9?M3eqW~MwDbqhilBJAMT>1EGtAST^Wsc{W-}rC#y5C*Cg?Hs}ZikWdo&JyRw1Th_2(myD9H|g|M6|wA7CmC*n7-6i3ByoSs;W=(l?Wb$rI|sdMN)sbqByBC(u^5 z2t4N|&`Snv%g7JARn11dN@grLyUtA%P=)-KUwb&F662>^StJ)cE7xqIsRagaE|8mk$#zXO-R54>z0 z=WSPMl^f00su7cTiYAHM(^VC}4R-MUa8NTYx#EH>fWZEBY?xssdW-vol{!}sg&JHv zSp(Vr9tW5~vZPO{;lV!%ot3QtB*uS~7Uc))e|2~1sK=ND8i+?5fP+4GWU&Vjr8E+@ zzjFaV5*%7zO9neSd`UP9w4v6Bt}z2qEPZopeL?;V^R3ig$d`X+GLkR^;M)AkjPaoa zUif7M;?L0ycXkkyh=?xkf9Cx)#Pc=rCvuruU_Zv&zcJ@52T{OMapP<5f3-5?9jIS- z{Q?IGNbqVB1`?`t9xO+eFEa|9Y4ckT7!*qIN7U1&JcY! z`oI1%05?Y4X46%=K4>M3K2-I+98wX_D)ELqV&@a`*EBsGpFRYE$4`napcEQ!|G<>W zy7o)B{bh7V^b`E|Ax2kP0KE?6rq34=sIDZKO3nKf3q@^3rqDLbKXXt69pccl5#mrl zTG(GecZ4=53nr4kgJA>%nq*;D@$;Jb@@^aYiC^$GSFTb2I`(Mxl6u?Pf3HOVA9Sxk z>(EYje_9Cmfonlj$~m0yOCa4nNhNqCRSDexa=Sea;zO8pBe()EcOEy_Tm~k$KRewv zhD?(OUc`B+0OAlyk^miPOdRng;aQC@fZpM^;*@i12wb~fa!GMsThMPDhqmQw5;N^ zs{YX}A{`$UHB6`frp5hy>%huP>+mSFaKbO40eZ%mdib|u+aMX7mR&c63iK2@f0*%I zajdw5F#-eg)JEv3KvK)m3U(#5u)mN6cKDvH#XaOQD`DSn`O00%GE!{NZcH~R99J#m z4W6Yo-p>&szl||x2k$*TwP=b9ym&fO!@+-&Oe4@+{TuQY>iUg)fJEuIpCK#=9n!n{ zMXwKBDd-Lz3XImwZlQ@Ay8+7^=&eR#WzPjiSifKhv2Iy#yn`-oaeL71gtfCv625o< z$n+N1^X4Jf5`6`#iQ>++=8lx0vKmGfSSv7oi13GzDCFZ~t66YvrL^ENbspv$3&Rp! z&DjRdkD$Wkc;o>&a^c&Si~TN_BNy~Z4;S(l%E~h94Gy1znI{RhXf`HN@Gs& z>PiyTIT8@JHhE6c^gDZGOb*42?gI4Yudc`;bcFUWgG%R2(YJ9L_{DEBBMwRH3jk$Oeesf$dwf3w7@3{|BWC5 zKRx~Yeu59Er$yWe_?UBG`-5zAe(5tS1;Zx?TKsmX=t6`&E4p>Ua)EGdYw1Y!pqlphEX}1 za4BUgECna}?)fdjn&}U#--&i==^J^(H+y^;Rf*qV7~(XbmT_0v^f94b(YDR3K?@gQ z&TcA#x1>++Fds57@6*_IK6+hyU)K7uOOs7*U9X6}mjw(i$?8TH`mu#GE~uPF`w8!} zJgYR-+=OeL?2_rPy1RLGbl=7eR$1uUK^&f7R}^`!o9>?*Kbj-pG;@GDna~o@5FK9d z%?hZJ?|CBSD{=K+%Uxug+N}s*e_8~t24wizJN+DCeQqFd%YBSPVr{*; z-Nl%sszcMIYN3%E8pwUt5cwdEhL@#5WqC4ULV6q@ai?w0OPejU@orX@| zIwACP`}No7989qm2k+pgS4XXf8Ukz>uDs?zvQRv=Aslw1bI>VxQ?ysAg5`o`$laS* z!rU-w)!}n_np12oEU|NTYZ*-zH_aAuGCW0>DkExgXkD9A4dx|YW;k|Li~mWwVo6P~dxWGGA3rC!R^#b=i4ZI*L}FPZeT(54ALt!e{yPsq!c zGEJR~f1?p)*s{y3>sWDBx+Rf8(l$zQ_H%@4(%Pmtk9p-9k@?!51WA(R8|-QNI;V?| z)VzG)yv$1owQH9A-c3Y5KYhJQB7B=LK%V0X8g(S`4xrdjZI2CHRn^aNT%ESv^Ld{REdx><)R+(&Hs91kmZ@5$03o>MO4zGaAqQ8R~mNpRJ-#o6(KtlEuvX?~vA)w?HU#?yJ&5IY`mR%&EJc}FZ|B7JYdf6{e| zTXeYF<9-U>p8s80Tx?x|`IoVDbGqT_B`n+rg%Q9BiHJ`aaQfJIc;0YzG32!Hu(H1a z{-wj|1ydZmPVOWCfleAbkmJmy%<6_}# zV}_&yf3oqivhcESaVkhJGYef-7MAEUD~gTX=+~3*HyZr< z)yC6AOT$UDMPywy<~eza4frGA1q|G4*+6 dLt|6R+wS4-_!Sg0AN8J0EkeOmxPDG`S&#dURp{_`Cs|3hX5ofh<{JQ`)@-402n4MCamI-9g{Sv zfU1Nx;&*RRMvq@le?+VX9p^s+hKjUV0sGv^>}VNnz18ke;um-ojf-lmQ{O)Xt{nrb zj?|z8IyR`5e0LfofMv$x{CiO>KLFgEC8>k;+4qF^FX9uyrqW$ApS z>`j+h9y3Y>x6gAi?pAgxP+|-)!3FjMwsfVvygIQ72#T+`E|Mp`cg9$j1cm5yEonr* zzY8H;^=4#?vy*%=Fa6sBmibR~o@U9bZ69`2?XqNVAQIEDW3-zwcs|IfK z3jT2H8dH>vR58a?(C;;P=K_fEfQU=R;cWJL1U-obwk7?no~CPP%p_*opF?4)r^g*; ze?sRz4KUeFcDv>4_u`H_>7=8lpVZ4Pt?P~RZjj<5D0)}nEmYf?{RJSxZg<0S{0Q4>?Pd8VnE+{Vd9hj%gTVhE`TeI+0JQ&=-U?Skrc!`9I zb`UxGJM9iQ2}wZ_BY=S=#}@{3lwcAO+g{S?p)wwr9^cv|m#ZFB`Z%Y1+8@79fBSr2 z`|vnL=lU^6Yx4#nQ3ek%Ul~#bqNAA_ejb|NZ+T)KtMvA&&l&DM{>&p(mk{*2%0}vT zyAjc|s{wbzZQGVuqZj)vK6Y!gTo6xtAi(Oxg?2=bpH_C51ZTDQtQ`O{N`eKI7zuyC z>@tG#S)M6x#U)LLvlms@bNmd8Ys0rMZZT0KMx3kq+(x6t0Ks!|ykp?zk&Pu#SSwy* zh8h!`j-|lL?innQH@uE1&wLl7l3BAokwoX}SNMqCd`VGRsZ_Uo`}`U)JQy7lbMvs7 zl&%*f>y{Mo#nr2$vENW(Sv|aI`)3956y^8U8hKZTI3K@Mi4tWQ`^)Xbtas-f=xct^ zAE&jA86|uUMcEh)o`5abHMzo_8jKxc<5*%naUJu-XHMw+YRO)+nas z!2qu%NgJ2=5xv}I!QqR~q>Dos@AoaB;COe6Ocz=txVJ!(B9qkELDDj@_4vb+qQ%+L z${kndv;;YA6+maz(7tZqql&TUoafr_hB@_!Gy{(a4Pif7#v*}0sCY5dI)8i#-Ti5; zN<|S}b-SwD^Vn9of^1JQKAMdICn8?HIR8Q68n4n9ar&G+SV7!_9px4aL{p$H+c)B@ z0@_=7{coQ7?P;hAW3d-znMu(jkVMx`wRjL2F+(|avPylPEoCakhSTGw2Sl?7UZ;)- z4JO{#BH3X@p@vcsVOmj1lp@uN+4oTR4b?HQU+6j7y|qg_btHL0rz@8@X?y&yYys z*#}VO?EL9ll! zJaxVA2wDS$was1R$t~X^>?OpdLYK4!L$0qrQW6oLH;i4M<`07>>IfV6I&;nAwu!pKA?s(&ddWnRxEM0V3^A23&I#1^-+tibXUHa(8~vx?wlX@Y0~3{in*SQ>niGDdr;aupko(O&u0+=VNVba zxSi$uzScI?(P5im{{1+PW2pk4o&+-#hGXFTOuYAU36#D6>~-a;hBK?Z0CM@Mt^1om zL8)LzbK@(^1>0&`=&eAhV#N2bBYY04%CI7}8nAXh%pQGBkun--0CC=YCx^D*viXkN9 zaj=I^*KDH|Ocd!RphC#NfWGebTy*^>_xnvgg!xAv7-m{}vZ5{oAe5d;3^eh@NJjHi zTZ+S*I@s(*q3k)fYb#yo4H`>ZU1qMlc29`=Hap^c9SxBCb*NFu2DuCro=iL`0Yo<* zZsv?fe?o<*m1)tlI~>7JAnHi9Rr6I;OhPFQY6{UHKC$-8h;3twl;iwiJ@e7bZ?6(Q z!72@TvVvBGsc+|617Whlho8!|qpS+xwyozYU^+sA$g2#Jjk3&TP!*N>#6Pg2X-ow1 zmor1_=?jLV{l$b9JlSB7hX$Fk7GUU#zW;DO^j+WY9b~;T3)LDM&Mtt-Cqt`DO$8MF zd6I(me%KTM`w$qrqq8$Ftv}DubKePx>J7jIt0t4IumbNF0;ol(ER*hL1wz$X23DTm z{3XCr*~M=07qo-ln{$`ESzd)yN;Fkr!;cee1^?wHc&$eyf)GTp(+gqeF{ZJ4SN;ko zi>7Mg%+1Emk*~!*mP+9LHXdeSYdKOf*;{n|L2lzy7=~tXD(|wpqygj259}fKH!uQc zQwrm{<(h!+B6E|go(dG8AAjTw}gE)Plu2?R)%a-Lwj_6ZE zxUojpxl$vpT?meI^s#r<5s8Ti9470JjI4NRO$6kFw_ zo5`F;YGtPA*C)?QMZ81}M3ENxNp=2?udIT(^zzSev4OFZC@$~Uhd?uJ4HFn$ih=9<*4?tUWCp@If=+KPcumD%&b`b)C==&s4p1YxI1JeoV z_Fa45A(dagZsJb!WJx(wMjfocC~}@9fK;rYme$Nc+V8k+VROnt3WHy3BR(J-&?Z_o8bzx3iNk>jwU;w{+9da<}Q`!$HvC zBxkb&w1Zdo1$%t{tLjhi6KbF_-m;?J|6%Wl{?!xxuf22j@_GOOAkP1@cf;zMHn^+k z{7nw+QN>gdiQ*HD^y85^DlCV^?C~Sw)@Th0&35y^PYV!S5G_A;X~9U}hbBUBm}2?` zAV}kyRCZjoB8RvP=sMYOM2QA$i$`=8j)^rwjkNXVo z_4fN!l4DNG^$u#VYCC4@?>`uGQTB6SxS~kGzTvswBhLg<>f7plgrAoLxG$XSK`(Cf za{14r*Ph2hA${mKpDze8{?9BZz@)9#C2H^Kj6bg^)v%spx{viCs;<(oh3+}B*1kKK zfw&wO)d;Lr{e`^N+UqDE;>c#9xz0Qf%`j~{plMX1-&D{arJz+z$M(F& zp|2xw!MAk5I@qSagB3-g5=tf5*qifdaYTPIw&3EvbV>Ny`%^`}tb%?^wwCXwfCkk+ zx%hE|52V`PxCskgO;h9jdXi@22v(x9lA`!2HRt*$;BH7TNNS!SReUr0>&rj&m}0x7 zMlI$==FOrxO1B0Q^d636tE=9J) z(bpTLEJ>Qn1ah2U&UZk+`}Tp!_2w5U-}3-5mse{61rP-p>%|4@ ze6?=4M*Ei%A&jbu0}pz7>A1O8@zHn{jZ+7olP7u@r2K9wZ>s@y(vU0QBwM8v}ht3p6?|Wl2c(h26GP|Vv&u3mkUf!)@t8PxJL86zl?;Z7jkVX z6_T^a1<5xC-3w?AmC_*;+wos$FdZ&d-p~NjZ&4)v=xBm8&THiQqNL`c9wvh2x%-1HJ+v7(vHL zY_|LYfv~<59)YAGix{M-XnWsK3HzF~1uZsMDm}uIzTb#5)D;6CF*M9*$FI z1dWuH8R|_GQh@Iw5qF?pX~^1PVMs`f=Fics_f4Hc%IAzUtjZfWo}pYX$c~Q{wmf@m zqQzz*7lua+7gAaAYzy*4iO7MCMZ#KQh0TaM=uzD2z0s|RYwz8AHMG))u0MI^`cls1 z?A@{cx;-lLIBW=NKX|fstiw>TP*^?%{Sa*;%+>uz@v=B_T1DV^eLH{O`IxyOFr+21 zwDlb+C^cqgXBKF-?Z31k?TPJu9_)`4niE${Ee`Az-H^@5Xduz{M;3?eVGv6_>1eF6@hU`Tt*Q{6tfrksEZeP4e>lg_ z{YI1|+IK4%U~n7abzP3XbYJzxkqpu;F%q=l^H!Il`pJQRk@3mxfQ%%Q0P`?( zgN2lGMr(gpEpR3pov9}f%9b1m3^q(`G$#ih`NKUQOKi8MM$Lht?m3j-f1q{Qfkr9E{UG4?S3}m#0-OS1$ z?r&b8DMzrS*hWb)Q0&HmRWLY+KzqfzrBVfhN&P_BpN1LgggO+$r4!eJF4S-LCQvy* zYYmw7V;@1OmuKwzO(7<4B3v>=I{cSNxt|0jAIsq`@URLC-2(du+~v;Byeyh8`p&zH z;4J({w1heP%-=r{6wy_?>bZ66DF@uVm6x^0>H7E@SP=J|^Y?k@aVL2oJTK>gXm`s0 z8>#w#?D79a!2neM>m#GT+HC;__){XMPJNt>I3?$A=Wj$o-YyHBp^N@=<7Mmec(y_ixL`{;iVWcs5Bg z+IMV*hm}@gtrIBgbOF&Lf~2A`N|upIRDmuNtWt*g94svlRHwgzxI$@Ugor>0J)|cT zguQ}#DD+n$3yAo-ePBlkX(+&ZyL3gmV{gZe_sUyvutz`MRxzRuSLciGX2zi`z?BxN zQ6}EJ4#A4JH@7+B;Q3MT6Sv;>o+2NkT)P7gqY2uKM{SvY^oj5juSkF}RR1^BRxGD16k`j1 zH@w_=W_c?Y7rX`yJX2GSwab#V_j6rru*t~_hd7V=@8K;AjTV@$+%4Ee#8r{r29%Dz z_N{rUW<)f(xUR1Enqik)Hj>n)iVlM=z5j!6zl~ zk&9l)nj;FAf{Te$DUsmI5wqh-?04gqI0-B{VgnP#WuR_ir8L$pg~U~{@yI#2WF`$8 z(r>qKQMpjKqr{Pyw$#PeP0)-8JU9|Y3g;fg6$#3Sa*`FTS_afq0)WmScF9sNw6Wni{}z{OwA= z=%%CDzR)51Qk^DvSP{uV=djHl0z2+E#ADjFI-Ja+i8 zkn9Yxzc<78zW3r3FiPoAk+(GsUy2s~Mf;$Qy!p;f9;!SwLzDc50nT)A24PpA1k8vEPBuhU)bQBQ|#qA=)g%s#Vj|$_fc9t&9^v7fUqRf>V^5WQ?UE;v6j?)!V{DKO*t_<%o5*C@L~a zkqS&YvEK71>X-ArsEVqpQ#d_Fgct})e6=2P2ZH_y@X$J7%F4a0jlh>Z+W?;7DR6tlJVH}@$tPcax*L&%rmsu9MGi5#n2hL2yk$F zB|C(L^g?*hN{4#W13%)Y48-yzqbdvO1B`hV*=#L_oa^wk@Pr|D#^uXp;7o!{!uH4* zI+M@@VsB!UHcsUnR8rXkS+_Z^WD?f)d+>e6ONM%rk%VFgy> zp)j@=*wc~nE+v4qICNi+GP-@ig?>!k%+0TZ=jd@#!m~uUdUU>Ope0$3zX#pCeL`&! zl-V>w1;Pc2Bc{;vQ^RtAxCWNhtb@6tS#@^)%3rkVm?%@$c2_5?)#>VwQ~8lOMPWu@ z=VT}h_DGEVL+WrnI7NLt0t3esQ2eh6qB`SGXIW`sh04u2jag~gkSy4=4adJrlnyGX zKl2d|IM88MYuyJVD@}&`ZJgk$aMcZoCO2_tJl>>jXXs{y3Kq6s4kOQYjIzS`7MLlw zm$65 zN$VX)p*8dObjT57@O0}jCNe-u(+$|hBlGO6#0C@?R@-_(B`5|9R+YeKM7YI})QF!m zO$#zRF4oUb!1o5>DUzd)ZMX!Ct9;HT5$@g%)3$H_34%&EyF4wC59wR|)LdonA&t%q zaUmAUkbP&zY$luDr*W1*rDpt7JVd`m=v~vnn3b7~C+17Vm|AUup^Z$r1 z{5vrOF#RugQU|_<0RS-J|8b{fEod9OW$Z6AGle?$RXtuuJl+UVys%)hbO&Pk_XP_U+Zq zVz`ht`VbS&Js0Bf_C7Fp!Kqaxq&s*No@3m3GX*KAmd996GdnqSGqkiqr!$eM%53Tl zH65=6t{tE2!BcyLf^W`Wd+kjajia%PKu2>BE6TqIeusoolBjR?k_Di!7;1~LDLO3g-=RVVdB%0Sp3i)AX9}=28a_zqwAzVI zqHp*idhb&(^F}O=ZFnB&$4UiMEE!>=h zp?vL+-)J(4f8gF!rRj<8p1QRYc1+bfo?M#d>I3!l6?N_{ww)GTMUXJ8R?FUlGr`W> zd7(!J8u%~14(m{RlFZGvLIn%%vWf2x)~hN6AoKj@{Y{z(Q->Fs$bn2=LB5V=fzSLm zL-Fd^DoNuw%z|xCu6rX&N=mUNEYNwgaRNNPcZ=@@uf|M~-N1C3Y>U@j|8}#)R|(E2 zF%oJh)9_c9z&w23z#_>kLz0s|TeUEF<}8S)$=H1Ih(i0kC=VutqA^;36V5nGSg^p; z3!V@uwo26Vzh@FnZJ-50P+FXR|wk@dQ{rtn33ZtX=m#1K9$#@S06aep3tR5$$o5lFWvwl$bmNW#BRf z*%ZN;I7cx9-Rh3KsZ&AE8dGNB0t*Rk z(nne=fnr1^NmQ{w5-XoWOcnE+r{nAlNH)kU^+^rqGPJwC5mLF{w97!#-;JG;-r>N* zmuqyf_Daox=dGRBK#knTUdARWkcfxVAck{`Pr2KS(Z`cdj7;DklO0xC>9CG}<1SPF z)4D$2E0#!2B#bK!?I5PbudkApag-dB5%-?HYdVy3Y2kB zhPU60X?wDiNyP2ClA`}ruHMIf#L4ZZDB`g<;j{5z9#l3Rj`STIv_Y*y(alqdh+h-d zi+NQzi290;t*jq`?G6~2j07|i!t>w>VtDJ-wqE2!qSR=aETCcuAxSX6;-`Y!1xB%^ZDdqbumN9EQUEcC$%M>) z0F@`^bG)J%_&Y9c{b8TE7v0U{{NAk2^=6AY-k=h@ImVP3>yHSNv^HD?{@Zw>Y1_eu zuQ)#mL2@?c;rsxj`yfMx#Ed|1+(c?SD74a(LETIZx{TqgC3PVB_VjTEIg#6=U z?7J*+&1PVMtk}_1$|_RzyPiFwY$P3=`$;YTntOh3;t+x^Ji4YZ9Y&F+JXV3(T=+PI zggOeFILfPhyP${!E$rv@gNfYOG8 zR?qj*wR)>@9g6*c#-!nA?9c0Iutq70&Jp<+$I-Z}&}pm11EJJ0PPLk$Ud!OL#ln-o zd(0vuvyNQ5cW)5cEVV%NG#x4o(W3qhjzy8iMKlA01bYha^?SzSQ_poOy=NN({CP#FhS4uROWWFOuh&esY zVot4rvU3oG3@R}lhQ7*HdUTh_fw3~|9XgkUg721;WD{K*U{^vuYcK2v4KjNZAMh{7 zOkdlP`yfUNrM95pspFkPNOcycgNSmewFCnra5DpxlGoP8OskB?`a{}dD}4+Hxr+W* zNr@qdYzg>H?<$~VDnV1zkuQH)iw0cB@6yU6l6B;q>^Wn&sMi~#s>hcbBUhXC*yk0c zb9i!Q@U{X?s1QBXRFm6AeQN&C(iPOjBaCOpW=nR2RO9O3%jtGAw_#DYT~w=eg%l(? zZ2M_99N^6Y3$|RW#ewGO-!q)&jC~SLEiHMdWkV-fE}12T^^=}YNXNzJD>6=-WmVeq zSX#3gv9M>Xe^zNGZ0XYW&7L92S^n_+c5krPHYjfaqL-0~21!$tUpipQh(n3YZfO;c zPS*8NN4T}*5=_r`4||FVR~z(BxzYW!D9O`PZX*1rElBVx{ur6Fn)sTC7ZF1xQc4PX z;2L?ZX(zIuZ@I9_u3hh=85s55UAZ&Z)#**-L!ho@x^lD*&|xSx_n|*W0pJuVywL3Z z$-L>grDir~XT~1)e_HU#^Wr*G?F* zxU^hGh&u{n;KwtpeSgx_`v3#FzU?{eq!Ywy>H#-L9e*9GIdoi&2XvOLZDk6=FAB#l zG3M9y9K!F4e=x9WlO4lwK0m&_5810NL;o#)qzM15VGMLA@nVywo@efl?jPbpXwI=1 z?e=sYr!qIV_MPDC>P8EQBSwi0x0n>yO_`uIt6b7FM~^UKfs{kb+U=&AML=-OTf+GH z5JbVnm>c3!p=vL4Q5Nm2ki0ZVE>UwNH92OFBzkgzGBj0*<$uAms*@v)%%v*0WlOPb zG;yI^``a^$V(-lx5zfY5JhG56Y4>5nggxrfesdM^e*dg6PUQJTpiB2j=bKUS^6d8t zp?xPYX=mYwj`AYyJ$93=X^K?6#}Z5}0gG0VczFd7m(iJ^duDgCbDrKD*YiDBO=<^u zA*aN^Zg8CSa1VcIhiL2v{W<0g3zYnjEtluKz&7aqtgQzkcN4s}HQSmTIs|)xUb`o> zeeClI$2YjmQS%8{aN7T*p8dZPe1!g03;i#Ft+aI91^{3i{v)tk+SWE`tLR^5?w=;V zTN{|+IqLVY;bMuA^a>*J+2Jc_#Gp&%lu&pjY`*{zWK?SO`Ld5hRRLaD5xCtCs zC_ixZO8UpF`k=x+*OHA+QF?FQLt<{d+<(pH{GRmS@4BydtPnIYb2EFo%3gJ!;ddW_ zz<0nnnhH_(0!@swP1sFh!i*y(Bb+c4=Ji&Frt9f#zl)q???t^Tz8_?_?+X(l^p^i1 z6bU>u`bO{E=DP`-TP^R?d;q2N&Lzo1)WiahCrcJ;Jnp#ry1;Q^cmbdaOrAV?kGP>S zbvU2~xzWpfYDd7@UTi+!@Zr8cGzjLCK@E`!scr9_@qUG9v@ z==%S75$2}mf~-pVYR0sGm=g`JF;(B}gFby5MnvM}Vd@)(?*f-6fSy)eyO)lWzy`s< zceAKtEXxX1CT3gUPT6IEsN_oz{WQpEr?~+(ZqObWa$yX%fciD&TP?@m#!Q@4I<3Ao zOUuUi*mkZd`MmMCk|x`3QoFv#Wp-B`5NFnt{Es_!7n^UCrw`BYbODS(h`Q!PwEU`S z*Bu-2lWPV9o|&3fs+?;^O*+^N0U3<3JrAm4B`5_CVry(Y{VoXb>z=UKEz|nm#Msjl zN@I`RNfPj(Fvf65l9Lp4jlr*_>e7(N3Eb0*zar-5m^+m5=1{}nLEXhcy4v-Q;Od8e z)$iwB%M*VU_}D^(!cla|T@OjL>%luu@8XtbRd+MMs*Hmb&9b++ZpJ{H&+0;PU(H-< zH;@f+qjWCRQEdpSd%@5Ram{y8rH0PAe3o3vM_~>r=@R~r7 z=!0Ik)*M(2WR=}W?uZ`|>!XVf0Ss@XK*-A;ez~BMWw&bvj@=J}yv6Jf|9b7YVS2lq zIM=nXv>zR|UPa2XkhFKDoHCLklS)NOWrn>Y*+)0E;`2LV*;qdb?Sx=k=DamFHZPgb zmx>m#??%TM`C2JK5Tnb9fh5!q`JchltHzGQ^g>z)Te8ET8nO?xMBT0AqQs4n@$yv@ zxC>gQ`I%ecRWb2<%fnfNlB1Rp$y3)m}}2XMOO4DUxA=r2o>~w!xrFI z9{_I`6i^nxo<4^8wiDVs$Ci@!aqD|7M0+PoM_bI-?9)gx+O0SbB#73*@ADnYM1dXc zngx%Ab|=I1cIZ*NH!Cl6xwZ?zXxDDSF7Z#7m`q~y48`^-gUt0syxOruh_*!wl~$aB zShmxct!BcSuw--p6?!U>CvkW>2 z6P}KtHHk5TOcw8Ot*A!o^4ScAc(o<@kd7sr#T`PZ{cqLGF_c*Bzg`h+Ue`UjP~BZJI|miNzz_+HVW^0LmU_g5J$1 z>+F}VZYXrSQc0@K&Y-X$sGLCfuZRS{zjE0ki)JLcMeG68C@x`&|F%iItJXja7;|(uMr`aYyZG<}Xa)P1XJJbEOw|Aq{)b=l&ef5AzH2A$ z_c#G!8&h;6iy1~{<;@$Vc{cs?2W2m^POnkxhSGK?_Ww9Gc!^wKhKhv-Nb*62Sd0fv zhF|Oy)-1h(DN6?-I>S;c3?vD?GJslow)h8ZfC?8143Q!z$yT=D5=$GBQu&PN<6|{4N<_K=Fw4kLI?qz2bY+01j^-_O0uy&gl2&_Z8JGLn0(y_C zbsB0D=cet5eq3%}yQB14`Y<|qh>$kM@C**X78tx{U!8F zi5`!q1oA`~FX^!kR#rH$!)h`R+PG2IdM%=&Qfwx3xrABLe(wsO%HPIy;VKq9k@{Qj z-#A-FsE!o5>DKi97Y-7!VcEd5c!sgd;RlFWjX4xT?CoD1*Us^Lk3#oL6`9h&KWdm; zAizBP#}}04z~hG_5wY8_gzE)`tGPEjFZZ?YBuHbBoi+V6w~U>>YC?8tl#4sE32R|* z!tc0&N0Bbe^p`ArSfyw}3f}N$? zP1_DA6%NLBVx38R4+(@6TStaAh4`=+20I7Hu!P&oGnb*r(pQYA9sO4_i`E~d7GdgR z+92Ot3km8ehVMwItz_BmoIfWGe+_8M&HiZ6pjfqvL5`k*l%*Alj1n`tV!=QN3c2s} za#?z+*8YAWAJ- z+Vv!nSiQ?=WXJfoR@!-dpM>Xt^FaNUBv8aEE$n3I2$JcX{AY+tep6AGn@vn9RtGHpPs)U<| za+%w_r_hg_+Fx51#;7EF`+G#4V-i}%qFMykw7ruHhLZX0^8CV9@pJH8x)^wZ?SdbG zsTNE6y{N?!3kxf=%Q`bQw$>Y4V`Ef7vnH*C*9ScQHseZmvm_#2v*|EdEHG$WY|N;a zN7!XXhpk1tAgP+pQj9#pZ%Ae3#Dv7`sS_ybgG6L>eDT?byScNgtC7w&7`9Q=7>wUy zQjM&LH!Xu;9@f=rJ~{C;4c1+g&7r#m!iMnz))02v-7$V!<@>2+66IS0p>jc^Mj!2t z>CDD|{jWnvWD}Gh)6RO_TDN1IR(!k&D2T)nKS9hIwdkzfOw7k=XMB8o>RH+-O_9Qx zW|MQpO-#hbjw4VwFy32LeybL!h`_w?|Ll`U_fJ^S7-ORhaE*X%@uQsx4X2K2-c){6ULc!H==y>5diittCY!+e9o@(tB;b75-TlPxN z`&%(tMcNwMlJX(&PmQA+;OVT5-&Q2QAYhOpL6mc%D244Duu0#X%8|F&;;~nR3%|Bq z-bVzW!iC@zNxiF)B9V)+iIzJoh?_30tvUGhb_GO%YQ=vz1*zS0!Ibf%RxpZtLeqgQ z!d~OY=wETqAhmZ*lx-UrQ-L@}h?8bYqPl}XK?-7}GXTND5rq3nxIX`Gvk@C@@PVn4 z8t5+g1)fbvW|4Z$X%k9)zRp)Z87LaxXAZl0U1x$j!Z{=Tzkffn0L`!L`|A8$@YwV+ zm(M2tOQEf<>{)_d{>@^UUYERsUcf)ja7VX;lUhUv(jSS%OpJ8joe>&^g62ciLare!Qu}u z|1ZO3!WFcfAXyLZMTW1f^QwMEp-j+2Y=3f?;~9lh56zcdkMCc2l$JPhg>EqvDN{)w_~cSZfJ)e^K(raRn*Z?~g|Ad{9;_ z(Y+H_7dxlcob5;CMKIp{+flhNDQBAo-geU z-})s(Ae%Gj`A#4-XKSD4eVOQm!I83b{iFF;YV*!O1a8Ta7f4B3a}-^?(l12()ISG5oMRIr|(w0hpFe~bEJI!70*-zuD zd;Yo}tA6a!rp5}GrB|xEv&wp{nODd$JyVAcQ@lQfdhS$2@jEEvi__vtk5#P zjfG>3eZ|P3#A2mlX6aE;YhlPS`?+zpX%Q@$d4Jr~$^2K;PaGGc+h3QS?u7r7S4Zzo1L|X~ z7to|W!%0AFVeD_>oRsuVIjal)2*qRt|n31DG;d* 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 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index c32d16d94..fa3401905 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -336,12 +336,16 @@ impl<'h> Evaluator<'h> { }; } let v = self.value(x); - Ev::V(map_unary(&v, &|n| match op { - UnOp::Neg => Value::number(-n), - UnOp::Plus => Value::number(n), - UnOp::Percent => Value::number(n / 100.0), - UnOp::At => Value::number(n), - }, op == UnOp::Plus)) + Ev::V(map_unary( + &v, + &|n| match op { + UnOp::Neg => Value::number(-n), + UnOp::Plus => Value::number(n), + UnOp::Percent => Value::number(n / 100.0), + UnOp::At => Value::number(n), + }, + op == UnOp::Plus, + )) } fn implicit_intersect(&self, a: &Area) -> Option { @@ -711,8 +715,7 @@ impl<'h> Evaluator<'h> { evaluated.push(Arg { value, from_ref }); } let mut ctx = FnCtx { ev: self }; - sheetcraft_functions::call(spec, &evaluated, &mut ctx) - .pipe(Ev::V) + sheetcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { @@ -862,7 +865,10 @@ impl<'h> Evaluator<'h> { let mut out = Vec::with_capacity(rows * cols); for r in 0..rows { for c in 0..cols { - let bound = vec![(l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0)))]; + let bound = vec![ + (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), + (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), + ]; let v = self.call_lambda_values(&l, bound); out.push(self.deref(v).scalar()); } @@ -1323,7 +1329,11 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { } } } - Expr::Call(n, _) if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => dynamic = true, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + dynamic = true + } Expr::Name(_) | Expr::Struct(_) => dynamic = true, Expr::Call(n, _) if sheetcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, _ => {} @@ -1370,8 +1380,38 @@ fn is_special(n: &str) -> bool { /// Names of functions the evaluator implements itself (merged into the function list for UI). pub const SPECIAL_FUNCTIONS: &[&str] = &[ - "IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH", "ROW", "COLUMN", "ROWS", "COLUMNS", "AREAS", "ISREF", "ISFORMULA", "FORMULATEXT", "SHEET", "SHEETS", "OFFSET", - "INDIRECT", "INDEX", "CELL", "SUBTOTAL", "AGGREGATE", "TEXT", "LET", "LAMBDA", "ISOMITTED", "MAP", "REDUCE", "SCAN", "BYROW", "BYCOL", "MAKEARRAY", + "IF", + "IFS", + "IFERROR", + "IFNA", + "CHOOSE", + "SWITCH", + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "AREAS", + "ISREF", + "ISFORMULA", + "FORMULATEXT", + "SHEET", + "SHEETS", + "OFFSET", + "INDIRECT", + "INDEX", + "CELL", + "SUBTOTAL", + "AGGREGATE", + "TEXT", + "LET", + "LAMBDA", + "ISOMITTED", + "MAP", + "REDUCE", + "SCAN", + "BYROW", + "BYCOL", + "MAKEARRAY", ]; pub fn is_known_function(n: &str) -> bool { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 4b28cd049..72724e8b6 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -17,7 +17,11 @@ impl T { T { wb: Workbook::new(), calc: Calc::new() } } fn set(&mut self, at: &str, input: &str) { - let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(sheetcraft_core::parse::parse_input(input, self.wb.date_system).value) }; + let cell = if input.starts_with('=') { + Cell::formula(Formula::new(input)) + } else { + Cell::value(sheetcraft_core::parse::parse_input(input, self.wb.date_system).value) + }; self.wb.sheet_mut(0).unwrap().cells.set(c(at), cell); self.calc.cells_changed(&mut self.wb, &[(0, c(at))]); } diff --git a/crates/chart/Cargo.toml b/crates/chart/Cargo.toml new file mode 100644 index 000000000..081820745 --- /dev/null +++ b/crates/chart/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sheetcraft-chart" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft charts: data resolution, layout into toolkit-free primitives, sparklines and a small rasteriser" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +sheetcraft-model = { workspace = true } +sheetcraft-calc = { workspace = true } +sheetcraft-numfmt = { workspace = true } +serde = { workspace = true } diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs new file mode 100644 index 000000000..8ca961089 --- /dev/null +++ b/crates/chart/src/axis.rs @@ -0,0 +1,164 @@ +//! Value-axis scaling ("nice numbers") and label formatting. + +use sheetcraft_core::{DateSystem, Value, number_to_text}; +use sheetcraft_numfmt::{NumberFormat, format_value}; + +/// Largest magnitude we plot; bigger values are clamped so ranges stay finite. +pub(crate) const MAX_ABS: f64 = 1e300; + +/// A linear axis scale: `min..=max` with gridlines every `step`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Scale { + pub min: f64, + pub max: f64, + pub step: f64, +} + +impl Scale { + /// Tick values from `min` to `max` (at most 51). + pub fn ticks(&self) -> Vec { + let span = self.max - self.min; + if !(span.is_finite() && self.step.is_finite() && self.step > 0.0 && span > 0.0) { + return vec![self.min]; + } + let n = ((span / self.step).round() as i64).clamp(1, 50); + (0..=n) + .map(|i| { + let v = self.min + i as f64 * self.step; + // Snap away accumulated float error. + let snapped = (v / self.step).round() * self.step; + if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped } + }) + .collect() + } + /// Position of `v` along the axis, 0 at `min`, 1 at `max` (clamped to a sane band). + pub fn frac(&self, v: f64) -> f32 { + let span = self.max - self.min; + if !(span.is_finite() && span > 0.0 && v.is_finite()) { + return 0.0; + } + (((v - self.min) / span).clamp(-0.5, 1.5)) as f32 + } +} + +pub(crate) fn clean(v: f64) -> f64 { + if v.is_finite() { v.clamp(-MAX_ABS, MAX_ABS) } else { 0.0 } +} + +/// Picks a "nice" scale (steps of 1, 2 or 5 × 10ⁿ, 4–9 gridlines) covering `lo..=hi`. +/// +/// `force_zero` always includes 0 (columns, bars, areas). Otherwise 0 is still included when the +/// data sits reasonably close to it (min below 5/6 of max), like common spreadsheet behaviour. +/// A little headroom is added past the data so the extreme value doesn't touch the frame. +pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale { + let (mut lo, mut hi) = (clean(lo), clean(hi)); + if lo > hi { + std::mem::swap(&mut lo, &mut hi); + } + if force_zero || (lo > 0.0 && lo < hi * 5.0 / 6.0) || lo == hi { + lo = lo.min(0.0); + } + if force_zero || (hi < 0.0 && hi > lo * 5.0 / 6.0) || lo == hi { + hi = hi.max(0.0); + } + if lo == hi { + // Both zero. + return Scale { min: 0.0, max: 1.0, step: 0.2 }; + } + let range = hi - lo; + if hi > 0.0 { + hi += range * 0.05; + } + if lo < 0.0 { + lo -= range * 0.05; + } + let range = hi - lo; + if !range.is_finite() || range <= 0.0 { + return Scale { min: 0.0, max: 1.0, step: 0.2 }; + } + let e = (range / 8.0).log10().floor(); + let mut chosen = None; + 'outer: for k in [e - 1.0, e, e + 1.0, e + 2.0] { + let p = 10f64.powf(k); + for m in [1.0, 2.0, 5.0] { + let s = m * p; + if !(s.is_finite() && s > 0.0) { + continue; + } + let intervals = (hi / s - 1e-9).ceil() - (lo / s + 1e-9).floor(); + if intervals <= 8.0 { + chosen = Some(s); + break 'outer; + } + } + } + let step = chosen.unwrap_or(range); + let mut min = (lo / step + 1e-9).floor() * step; + let mut max = (hi / step - 1e-9).ceil() * step; + if min.abs() < step * 1e-9 { + min = 0.0; + } + if max.abs() < step * 1e-9 { + max = 0.0; + } + if !(min.is_finite() && max.is_finite()) || max <= min { + return Scale { min: lo, max: hi, step: range }; + } + Scale { min, max, step } +} + +/// Exact fixed scale (100% stacked axes). +pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale { + Scale { min, max, step } +} + +/// A number format prepared once and reused for many labels. +pub(crate) struct Fmt { + general: bool, + fmt: NumberFormat, +} + +impl Fmt { + pub fn new(code: &str) -> Fmt { + let t = code.trim(); + let general = t.is_empty() || t.eq_ignore_ascii_case("general"); + Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + } + /// Formats `v`; `step` (axis labels) fixes the decimals for General. + pub fn format(&self, v: f64, step: Option) -> String { + let mut v = clean(v); + if let Some(s) = step + && s > 0.0 + && (v / s).abs() < 1e-9 + { + v = 0.0; + } + if self.general { + return general_text(v, step); + } + let mut t = format_value(&Value::Number(v), &self.fmt, DateSystem::D1900).text; + if t.chars().count() > 40 { + t = t.chars().take(40).collect(); + } + t + } +} + +fn general_text(v: f64, step: Option) -> String { + if v == 0.0 { + return "0".into(); + } + match step { + Some(s) if s.is_finite() && s > 0.0 && v.abs() < 1e11 && v.abs() >= 1e-9 => { + let d = (-s.log10().floor()).clamp(0.0, 10.0) as usize; + let t = format!("{:.*}", d, v); + if t.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { t } + } + _ => number_to_text(v), + } +} + +/// Formats a number with a format code (General shows up to the precision implied by `step`). +pub fn format_number(v: f64, code: &str, step: Option) -> String { + Fmt::new(code).format(v, step) +} diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs new file mode 100644 index 000000000..2b892d3cc --- /dev/null +++ b/crates/chart/src/lib.rs @@ -0,0 +1,158 @@ +//! SheetCraft charts (layer L3, toolkit-free). +//! +//! - [`resolve`] pulls series names, categories and values out of a workbook, +//! - [`render`] lays a chart out into a list of drawing primitives ([`Prim`]) that any UI +//! toolkit (or the built-in [`rasterize`]) can paint, +//! - [`render_sparkline`] draws in-cell sparklines, +//! - [`rasterize`] turns primitives into RGBA pixels for PNG export and tests. +//! +//! The look (palette, greys, spacing) is SheetCraft's own design. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod axis; +mod raster; +mod render; +mod resolve; +mod sparkline; + +#[cfg(test)] +mod tests; + +use serde::{Deserialize, Serialize}; +pub use sheetcraft_model::{Chart, ChartKind, LegendPos, SparklineKind}; + +pub use axis::{Scale, format_number, nice_scale}; +pub use raster::rasterize; +pub use render::{Swatch, legend_entries, render}; +pub use resolve::{default_palette, resolve, series_color}; +pub use sparkline::render_sparkline; + +/// Straight (non-premultiplied) RGBA colour. +pub type Rgba = [u8; 4]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum HAlign { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum VAlign { + #[default] + Top, + Middle, + Bottom, +} + +/// A drawing primitive in chart coordinates (origin top-left, y down, units = points/pixels). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Prim { + Rect { + x: f32, + y: f32, + w: f32, + h: f32, + fill: Option, + stroke: Option<(Rgba, f32)>, + radius: f32, + }, + Line { + pts: Vec<[f32; 2]>, + color: Rgba, + width: f32, + dash: bool, + }, + Polygon { + pts: Vec<[f32; 2]>, + fill: Rgba, + stroke: Option<(Rgba, f32)>, + }, + /// Pie/doughnut sector: centre, radii, angles in radians (0 = 12 o'clock, clockwise). + Wedge { + cx: f32, + cy: f32, + r_outer: f32, + r_inner: f32, + a0: f32, + a1: f32, + fill: Rgba, + stroke: Option<(Rgba, f32)>, + }, + Circle { + cx: f32, + cy: f32, + r: f32, + fill: Option, + stroke: Option<(Rgba, f32)>, + }, + /// Text anchored at `(x, y)` per `align`/`valign`, rotated by `rotation` radians about the anchor. + Text { + x: f32, + y: f32, + text: String, + size: f32, + color: Rgba, + bold: bool, + align: HAlign, + valign: VAlign, + rotation: f32, + }, +} + +/// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation). +pub trait Measure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32; +} + +/// Fixed-width approximation: `0.55 × size` per character (bold 10% wider). +#[derive(Clone, Copy, Debug, Default)] +pub struct ApproxMeasure; + +impl Measure for ApproxMeasure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + let n = text.chars().count() as f32; + n * 0.55 * size * if bold { 1.1 } else { 1.0 } + } +} + +/// Resolved data: series names, categories and values pulled from the workbook. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ChartData { + pub categories: Vec, + pub series: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SeriesData { + pub name: String, + pub values: Vec>, + /// Numeric X values (scatter, bubble). + pub x: Option>>, + /// Bubble sizes. + pub sizes: Option>>, + pub color: Rgba, + /// Per-series kind (combo charts). + pub kind: ChartKind, + /// Plotted against the secondary (right) value axis. + pub secondary: bool, + /// Number format code of the source values ("General" by default). + pub number_format: String, +} + +impl Default for SeriesData { + fn default() -> Self { + SeriesData { + name: String::new(), + values: vec![], + x: None, + sizes: None, + color: [0x15, 0x60, 0x82, 0xFF], + kind: ChartKind::ColumnClustered, + secondary: false, + number_format: "General".into(), + } + } +} diff --git a/crates/chart/src/raster.rs b/crates/chart/src/raster.rs new file mode 100644 index 000000000..5da9d3580 --- /dev/null +++ b/crates/chart/src/raster.rs @@ -0,0 +1,263 @@ +//! A small self-written rasteriser: every primitive becomes one or more even-odd filled paths, +//! scan-converted with 4 vertical sub-samples and exact horizontal coverage (anti-aliased). +//! Text is not drawn (tests check geometry, the UI draws real glyphs). + +use crate::{Prim, Rgba}; + +/// Largest image we allocate (pixels). +const MAX_PIXELS: u64 = 64 * 1024 * 1024; +const SUB: usize = 4; + +type Path = Vec>; + +struct Canvas { + w: usize, + h: usize, + buf: Vec, + acc: Vec, +} + +impl Canvas { + fn blend(&mut self, x: usize, y: usize, c: Rgba, cov: f32) { + let a = (cov.clamp(0.0, 1.0) * c[3] as f32 / 255.0).clamp(0.0, 1.0); + if a <= 0.0 { + return; + } + let i = (y * self.w + x) * 4; + if let Some(px) = self.buf.get_mut(i..i + 4) { + let da = px[3] as f32 / 255.0; + let oa = a + da * (1.0 - a); + if oa <= 0.0 { + return; + } + for k in 0..3 { + let s = c[k] as f32; + let d = px[k] as f32; + px[k] = ((s * a + d * da * (1.0 - a)) / oa).round().clamp(0.0, 255.0) as u8; + } + px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8; + } + } + + /// Fills a multi-contour path with the even-odd rule. + fn fill(&mut self, path: &Path, color: Rgba) { + let mut edges: Vec<[f32; 4]> = Vec::new(); + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for c in path { + if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) { + continue; + } + for (k, p) in c.iter().enumerate() { + let q = c.get((k + 1) % c.len()).copied().unwrap_or(*p); + minx = minx.min(p[0]); + maxx = maxx.max(p[0]); + miny = miny.min(p[1]); + maxy = maxy.max(p[1]); + if p[1] != q[1] { + edges.push([p[0], p[1], q[0], q[1]]); + } + } + } + if edges.is_empty() { + return; + } + let x0 = minx.floor().max(0.0) as usize; + let x1 = (maxx.ceil().max(0.0) as usize).min(self.w); + let y0 = miny.floor().max(0.0) as usize; + let y1 = (maxy.ceil().max(0.0) as usize).min(self.h); + if x0 >= x1 || y0 >= y1 { + return; + } + let bw = x1 - x0; + if self.acc.len() < bw + 1 { + self.acc.resize(bw + 1, 0.0); + } + let mut xs: Vec = Vec::new(); + for py in y0..y1 { + for v in self.acc.iter_mut().take(bw) { + *v = 0.0; + } + let mut any = false; + for s in 0..SUB { + let sy = py as f32 + (s as f32 + 0.5) / SUB as f32; + xs.clear(); + for e in &edges { + let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) }; + if sy >= ya && sy < yb { + xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1])); + } + } + xs.sort_by(f32::total_cmp); + for pair in xs.as_chunks::<2>().0 { + let (a, b) = (pair[0].max(x0 as f32), pair[1].min(x1 as f32)); + if b <= a { + continue; + } + any = true; + let ia = a.floor() as usize; + let ib = (b.ceil() as usize).min(x1); + for px in ia..ib { + let l = a.max(px as f32); + let r = b.min(px as f32 + 1.0); + if r > l + && let Some(v) = self.acc.get_mut(px - x0) + { + *v += (r - l) / SUB as f32; + } + } + } + } + if !any { + continue; + } + for px in x0..x1 { + let cov = self.acc.get(px - x0).copied().unwrap_or(0.0); + if cov > 0.0 { + self.blend(px, py, color, cov); + } + } + } + } +} + +fn arc(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> Vec<[f32; 2]> { + let sweep = a1 - a0; + let n = ((r.abs() * sweep.abs() / 2.0).ceil() as usize).clamp(4, 512); + (0..=n) + .map(|k| { + let a = a0 + sweep * k as f32 / n as f32; + [cx + r * a.sin(), cy - r * a.cos()] + }) + .collect() +} + +fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> { + let mut c = arc(cx, cy, r, 0.0, std::f32::consts::TAU); + c.pop(); + c +} + +fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> { + let r = r.clamp(0.0, w.min(h) / 2.0); + if r < 0.5 { + return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + } + use std::f32::consts::{FRAC_PI_2, PI}; + let mut v = Vec::new(); + v.extend(arc(x + w - r, y + r, r, 0.0, FRAC_PI_2)); + v.extend(arc(x + w - r, y + h - r, r, FRAC_PI_2, PI)); + v.extend(arc(x + r, y + h - r, r, PI, PI + FRAC_PI_2)); + v.extend(arc(x + r, y + r, r, PI + FRAC_PI_2, 2.0 * PI)); + v +} + +/// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel). +fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec { + let width = width.clamp(1.0, 50.0); + let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); + if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) { + segs.push((*a, *b)); + } + if dash { + let (on, off) = (width * 4.0, width * 3.0); + let mut out = Vec::new(); + for (a, b) in segs { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt(); + if !(len.is_finite() && len > 0.0) { + continue; + } + let mut t = 0.0; + let mut n = 0; + while t < len && n < 10_000 { + let e = (t + on).min(len); + out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len])); + t += on + off; + n += 1; + } + } + segs = out; + } + let hw = width / 2.0; + segs.into_iter() + .filter_map(|(a, b)| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt(); + if !(len.is_finite() && len > 1e-6) { + return None; + } + let (nx, ny) = (-dy / len * hw, dx / len * hw); + Some(vec![vec![[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny], [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]]]) + }) + .collect() +} + +/// Rasterises primitives to straight RGBA8 (`w*h*4` bytes) over `background`. Empty for a zero +/// or oversized image. Text primitives are skipped. +pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { + let px = (w as u64).saturating_mul(h as u64); + if w == 0 || h == 0 || px > MAX_PIXELS { + return vec![]; + } + let (w, h) = (w as usize, h as usize); + let mut buf = Vec::with_capacity(w * h * 4); + for _ in 0..w * h { + buf.extend_from_slice(&background); + } + let mut cv = Canvas { w, h, buf, acc: Vec::new() }; + for p in prims { + match p { + Prim::Rect { x, y, w, h, fill, stroke, radius } => { + if let Some(f) = fill { + cv.fill(&vec![rounded_rect(*x, *y, *w, *h, *radius)], *f); + } + if let Some((c, sw)) = stroke { + let sw = sw.clamp(0.5, 50.0); + let outer = rounded_rect(x - sw / 2.0, y - sw / 2.0, w + sw, h + sw, radius + sw / 2.0); + let inner = rounded_rect(x + sw / 2.0, y + sw / 2.0, (w - sw).max(0.0), (h - sw).max(0.0), (radius - sw / 2.0).max(0.0)); + cv.fill(&vec![outer, inner], *c); + } + } + Prim::Line { pts, color, width, dash } => { + for path in stroke_paths(pts, *width, *dash, false) { + cv.fill(&path, *color); + } + } + Prim::Polygon { pts, fill, stroke } => { + cv.fill(&vec![pts.clone()], *fill); + if let Some((c, sw)) = stroke { + for path in stroke_paths(pts, *sw, false, true) { + cv.fill(&path, *c); + } + } + } + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { + let mut c = arc(*cx, *cy, *r_outer, *a0, *a1); + if *r_inner > 0.0 { + let mut inner = arc(*cx, *cy, *r_inner, *a0, *a1); + inner.reverse(); + c.extend(inner); + } else { + c.push([*cx, *cy]); + } + cv.fill(&vec![c.clone()], *fill); + if let Some((sc, sw)) = stroke { + for path in stroke_paths(&c, *sw, false, true) { + cv.fill(&path, *sc); + } + } + } + Prim::Circle { cx, cy, r, fill, stroke } => { + if let Some(f) = fill { + cv.fill(&vec![circle(*cx, *cy, *r)], *f); + } + if let Some((c, sw)) = stroke { + let sw = sw.clamp(0.5, 50.0); + cv.fill(&vec![circle(*cx, *cy, r + sw / 2.0), circle(*cx, *cy, (r - sw / 2.0).max(0.0))], *c); + } + } + Prim::Text { .. } => {} + } + } + cv.buf +} diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs new file mode 100644 index 000000000..f584214f2 --- /dev/null +++ b/crates/chart/src/render.rs @@ -0,0 +1,1470 @@ +//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. + +use std::collections::HashSet; +use std::f32::consts::{PI, TAU}; + +use sheetcraft_model::{Chart, ChartKind, LegendPos, Theme}; + +use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; +use crate::resolve::{MAX_POINTS, MAX_SERIES, series_color}; +use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; + +pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; +const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF]; +const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF]; +const AXIS: Rgba = [0xBF, 0xBF, 0xBF, 0xFF]; +const WHITE: Rgba = [0xFF, 0xFF, 0xFF, 0xFF]; +const STOCK: Rgba = [0x40, 0x40, 0x40, 0xFF]; +pub(crate) const UP: Rgba = [0x3E, 0x9B, 0x5F, 0xFF]; +pub(crate) const DOWN: Rgba = [0xD6, 0x4F, 0x45, 0xFF]; + +const TITLE_SIZE: f32 = 14.0; +const LABEL_SIZE: f32 = 9.0; +const AXIS_TITLE_SIZE: f32 = 10.0; +/// Most categories drawn by category-axis charts. +const MAX_CATS: usize = 20_000; +/// Lines with more points than this are decimated (min/max per pixel column). +const MAX_LINE_PTS: usize = 2000; +/// Most markers per series (after de-duplicating by pixel). +const MAX_MARKERS: usize = 5000; +/// Data labels are skipped when a series has more points than this. +const MAX_LABELS: usize = 200; + +#[derive(Clone, Copy, Debug)] +struct Bx { + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl Bx { + fn right(&self) -> f32 { + self.x + self.w + } + fn bottom(&self) -> f32 { + self.y + self.h + } + fn ok(&self) -> bool { + self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 + } + fn cx(&self) -> f32 { + self.x + self.w / 2.0 + } + fn cy(&self) -> f32 { + self.y + self.h / 2.0 + } +} + +struct Ctx<'a> { + out: Vec, + m: &'a dyn Measure, +} + +impl Ctx<'_> { + fn tw(&self, s: &str, size: f32) -> f32 { + let w = self.m.text_width(s, size, false); + if w.is_finite() { w.max(0.0) } else { 0.0 } + } + fn fit(&self, s: &str, size: f32, maxw: f32) -> String { + if self.tw(s, size) <= maxw { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut n = chars.len().min(400); + while n > 0 { + n -= 1; + let t: String = chars.iter().take(n).collect::() + "…"; + if self.tw(&t, size) <= maxw { + return if n == 0 { String::new() } else { t }; + } + } + String::new() + } + #[allow(clippy::too_many_arguments)] + fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { + if s.is_empty() { + return; + } + self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 }); + } + fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) { + self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 }); + } + fn line(&mut self, pts: Vec<[f32; 2]>, color: Rgba, width: f32) { + if pts.len() >= 2 { + self.out.push(Prim::Line { pts, color, width, dash: false }); + } + } + fn hline(&mut self, x0: f32, x1: f32, y: f32, color: Rgba, width: f32) { + self.line(vec![[x0, y], [x1, y]], color, width); + } + fn vline(&mut self, x: f32, y0: f32, y1: f32, color: Rgba, width: f32) { + self.line(vec![[x, y0], [x, y1]], color, width); + } +} + +/// Text colour readable on `fill`. +fn on(fill: Rgba) -> Rgba { + let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; + if l < 150.0 { WHITE } else { DARK_TEXT } +} + +/// How a legend entry's swatch is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Swatch { + Box, + Line, + LineMarker, + Marker, +} + +/// The kind a series is drawn as (per-series kinds only apply to combo charts). +fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { + if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +} + +/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). +fn point_color(data: &ChartData, j: usize) -> Rgba { + match (j, data.series.first()) { + (0, Some(s)) => s.color, + _ => series_color(&Theme::default(), j), + } +} + +fn first_len(data: &ChartData) -> usize { + data.series.first().map(|s| s.values.len()).unwrap_or(0) +} + +fn cat_label(data: &ChartData, j: usize) -> String { + data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) +} + +/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. +pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { + if chart.legend == LegendPos::None { + return vec![]; + } + match chart.kind { + ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { + let n = first_len(data).min(MAX_CATS); + (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() + } + ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], + ChartKind::Waterfall => { + if data.series.is_empty() { + return vec![]; + } + let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); + vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] + } + _ => data + .series + .iter() + .take(MAX_SERIES) + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(), + } +} + +fn sanitize_vals(v: &[Option]) -> Vec> { + v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() +} + +fn sanitize(d: &ChartData) -> ChartData { + ChartData { + categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), + series: d + .series + .iter() + .take(MAX_SERIES) + .map(|s| SeriesData { + name: s.name.clone(), + values: sanitize_vals(&s.values), + x: s.x.as_deref().map(sanitize_vals), + sizes: s.sizes.as_deref().map(sanitize_vals), + color: s.color, + kind: s.kind, + secondary: s.secondary, + number_format: s.number_format.clone(), + }) + .collect(), + } +} + +fn chart_title(chart: &Chart, data: &ChartData) -> Option { + match &chart.title { + Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), + None => None, + } +} + +/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. +pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { + return vec![]; + } + let data = sanitize(data); + let mut ctx = Ctx { out: Vec::new(), m }; + ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 }); + let pad = (w.min(h) * 0.04).clamp(2.0, 10.0); + let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad }; + if let Some(t) = chart_title(chart, &data) { + let size = TITLE_SIZE.min(h * 0.12).max(4.0); + let t = ctx.fit(&t, size, area.w); + ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top); + let used = size * 1.3 + 4.0; + area.y += used; + area.h -= used; + } + let entries = legend_entries(chart, &data); + if !entries.is_empty() && area.ok() { + area = draw_legend(&mut ctx, &entries, chart.legend, area); + } + if area.ok() && !data.series.is_empty() { + draw_kind(&mut ctx, chart, &data, area); + } + finalize(ctx.out, w, h) +} + +fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + use ChartKind::*; + match chart.kind { + Pie => pie(ctx, chart, data, area, 0.0, false), + Doughnut => pie(ctx, chart, data, area, 0.5, false), + Sunburst => pie(ctx, chart, data, area, 0.2, true), + Treemap => treemap(ctx, chart, data, area), + Funnel => funnel(ctx, data, area), + Radar => radar(ctx, chart, data, area), + _ => { + let area = axis_titles(ctx, chart, area); + if !area.ok() { + return; + } + match chart.kind { + BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true), + Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), + Histogram => histogram(ctx, chart, data, area), + Waterfall => waterfall(ctx, chart, data, area), + BoxWhisker => box_whisker(ctx, data, area), + Stock => stock(ctx, data, area), + _ => cartesian(ctx, chart, data, area, false), + } + } + } +} + +fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { + let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; + if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + area.h -= lh; + } + if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); + if !t.is_empty() { + ctx.out.push(Prim::Text { + x: area.x, + y: area.cy(), + text: t, + size: AXIS_TITLE_SIZE, + color: TEXT, + bold: false, + align: HAlign::Center, + valign: VAlign::Top, + rotation: -PI / 2.0, + }); + } + area.x += lh; + area.w -= lh; + } + area +} + +// ---------------------------------------------------------------- legend + +fn swatch_w(sw: Swatch, s: f32) -> f32 { + if sw == Swatch::Box { s } else { s * 2.0 } +} + +fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { + match sw { + Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), + Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), + Swatch::LineMarker => { + ctx.hline(x, x + 2.0 * s, yc, color, 2.0); + ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); + } + Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), + } +} + +fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { + let size = LABEL_SIZE; + let row_h = size * 1.6; + let s = size * 0.8; + let gap = 4.0; + let spacing = size * 1.3; + let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); + match pos { + LegendPos::None => area, + LegendPos::Top | LegendPos::Bottom => { + let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); + let mut rows: Vec> = vec![vec![]]; + let mut cur = 0.0f32; + for (i, e) in entries.iter().enumerate() { + let iw = item_w(ctx, e).min(area.w); + let need = if cur > 0.0 { spacing + iw } else { iw }; + if cur > 0.0 && cur + need > area.w { + if rows.len() >= max_rows { + break; + } + rows.push(vec![]); + cur = 0.0; + } + cur += if cur > 0.0 { spacing + iw } else { iw }; + if let Some(r) = rows.last_mut() { + r.push((i, iw)); + } + } + let lh = rows.len() as f32 * row_h; + let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; + for (ri, row) in rows.iter().enumerate() { + let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; + let mut x = area.x + (area.w - total).max(0.0) / 2.0; + let yc = y0 + ri as f32 * row_h + row_h / 2.0; + for &(i, iw) in row { + if let Some(e) = entries.get(i) { + draw_swatch(ctx, e.2, x, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); + ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + x += iw + spacing; + } + } + let used = lh + 4.0; + if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } + } + LegendPos::Left | LegendPos::Right => { + let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + let max_rows = ((area.h / row_h).floor() as usize).max(1); + let n = entries.len().min(max_rows); + let lh = n as f32 * row_h; + let y0 = area.y + (area.h - lh).max(0.0) / 2.0; + let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; + for (i, e) in entries.iter().take(n).enumerate() { + let yc = y0 + i as f32 * row_h + row_h / 2.0; + draw_swatch(ctx, e.2, x0, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); + ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + let used = colw + 8.0; + if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } + } + } +} + +// ---------------------------------------------------------------- axes frame + +enum CatAxis<'a> { + Labels(&'a [String]), + Numeric(Scale, &'a Fmt), +} + +struct ValAxis<'a> { + scale: Scale, + fmt: &'a Fmt, +} + +impl ValAxis<'_> { + fn labels(&self) -> Vec<(f64, String)> { + self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() + } +} + +/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. +fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { + let ls = LABEL_SIZE; + let lh = ls * 1.4; + let ylabels = y.labels(); + let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); + if !horizontal { + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let y2labels = y2.map(|a| a.labels()); + let right = match (&y2labels, &cat) { + (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), + (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), + _ => 4.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 }; + if !plot.ok() { + return plot; + } + for (v, t) in &ylabels { + let yy = plot.bottom() - y.scale.frac(*v) * plot.h; + if grid { + ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); + } + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + if let (Some(a), Some(l)) = (y2, &y2labels) { + for (v, t) in l { + let yy = plot.bottom() - a.scale.frac(*v) * plot.h; + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; + ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); + match cat { + CatAxis::Labels(l) => { + let n = l.len().max(1); + let cw = plot.w / n as f32; + let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in l.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, cw * k as f32 - 2.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + CatAxis::Numeric(s, f) => { + let ticks = s.ticks(); + let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let slot = plot.w / ticks.len().max(1) as f32; + let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (v, t) in ticks.iter().zip(&labels).step_by(k) { + let xx = plot.x + s.frac(*v) * plot.w; + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if s.min <= 0.0 && s.max >= 0.0 { + let xx = plot.x + s.frac(0.0) * plot.w; + ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); + } + } + } + plot + } else { + let labels: Vec = match cat { + CatAxis::Labels(l) => l.to_vec(), + CatAxis::Numeric(..) => vec![], + }; + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; + let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 }; + if !plot.ok() { + return plot; + } + let vw = maxw(ctx, &ylabels); + let slot = plot.w / ylabels.len().max(1) as f32; + let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (i, (v, t)) in ylabels.iter().enumerate() { + let xx = plot.x + y.scale.frac(*v) * plot.w; + if grid { + ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); + } + if i % k == 0 { + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let x0 = plot.x + y.scale.frac(zero) * plot.w; + ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); + let n = labels.len().max(1); + let ch = plot.h / n as f32; + let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in labels.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, lw - 6.0); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + plot + } +} + +// ---------------------------------------------------------------- helpers + +/// Reduces a long polyline to first/min/max/last per pixel column. +fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + if pts.len() <= max { + return pts; + } + let mut out = Vec::with_capacity(max * 2); + let mut i = 0; + while let Some(p) = pts.get(i) { + let col = p[0].floor(); + let (start, mut mn, mut mx) = (i, i, i); + let mut k = i + 1; + while let Some(q) = pts.get(k) { + if q[0].floor() != col { + break; + } + if pts.get(mn).is_some_and(|m| q[1] < m[1]) { + mn = k; + } + if pts.get(mx).is_some_and(|m| q[1] > m[1]) { + mx = k; + } + k += 1; + } + let mut idx = [start, mn, mx, k - 1]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last + && let Some(q) = pts.get(ix) + { + out.push(*q); + last = ix; + } + } + i = k; + } + if out.len() > max * 4 { + let step = out.len() / (max * 2) + 1; + let last = out.last().copied(); + let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); + if let Some(l) = last + && s.last() != Some(&l) + { + s.push(l); + } + return s; + } + out +} + +/// Draws a polyline split at gaps (`None`), decimated when long. +fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) { + let mut run: Vec<[f32; 2]> = Vec::new(); + for p in pts.iter().chain(std::iter::once(&None)) { + match p { + Some(p) => run.push(*p), + None => { + if run.len() >= 2 { + let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); + ctx.line(r, color, width); + } else { + run.clear(); + } + } + } + } +} + +fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { + let mut seen = HashSet::new(); + for p in pts { + if seen.len() >= MAX_MARKERS { + break; + } + if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); + } + } +} + +fn value_range(it: impl Iterator) -> Option<(f64, f64)> { + it.fold(None, |acc, v| match acc { + None => Some((v, v)), + Some((a, b)) => Some((a.min(v), b.max(v))), + }) +} + +// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fam { + Col, + ColStack, + Col100, + Line, + LineStack, + Area, + AreaStack, +} + +fn family(k: ChartKind) -> Fam { + use ChartKind::*; + match k { + ColumnStacked | BarStacked => Fam::ColStack, + ColumnStacked100 | BarStacked100 => Fam::Col100, + Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, + LineStacked => Fam::LineStack, + Area => Fam::Area, + AreaStacked => Fam::AreaStack, + _ => Fam::Col, + } +} + +fn is_col(f: Fam) -> bool { + matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) +} +fn is_line(f: Fam) -> bool { + matches!(f, Fam::Line | Fam::LineStack) +} + +fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + // Totals for 100% stacking, per axis. + let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]]; + for (i, s) in data.series.iter().enumerate() { + if fams.get(i) == Some(&Fam::Col100) { + let a = sec.get(i).copied().unwrap_or(false) as usize; + for (j, v) in s.values.iter().take(ncat).enumerate() { + if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) { + *t += v.abs(); + } + } + } + } + // Stack accumulators keyed by (axis, family): positive and negative running sums. + let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); + let mut spans: Vec>> = Vec::with_capacity(data.series.len()); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let a = sec.get(i).copied().unwrap_or(false); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack); + let key = (a, fam); + let si = if stacked { + match stacks.iter().position(|(k, _, _)| *k == key) { + Some(p) => Some(p), + None => { + stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); + Some(stacks.len() - 1) + } + } + } else { + None + }; + let mut row = Vec::with_capacity(ncat); + for j in 0..ncat { + let v = s.values.get(j).copied().flatten(); + let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { + (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { + let v = if fam == Fam::Col100 { + let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + } else { + v + }; + let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; + let base = *acc; + *acc += v; + Some((base, *acc)) + }), + (Some((_, pos, _)), _) => { + let v = v.unwrap_or(0.0); + pos.get_mut(j).map(|acc| { + let base = *acc; + *acc = clean(*acc + v); + (base, *acc) + }) + } + (None, _) => v.map(|v| (0.0, v)), + }; + row.push(span); + } + spans.push(row); + } + + // Axis scales. + let mut axes: Vec<(Scale, Fmt)> = Vec::new(); + for axis in [false, true] { + let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); + if members.is_empty() { + continue; + } + let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); + let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); + let range = value_range(members.iter().flat_map(|i| { + let line = fams.get(*i).is_some_and(|f| is_line(*f)); + spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) + })); + let (lo, hi) = range.unwrap_or((0.0, 0.0)); + let (scale, fmt) = if all100 { + (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + } else { + let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); + (nice_scale(lo, hi, force_zero), Fmt::new(code)) + }; + axes.push((scale, fmt)); + if !axis && sec.iter().all(|s| !s) { + break; + } + } + let Some((s1, f1)) = axes.first() else { return }; + let y1 = ValAxis { scale: *s1, fmt: f1 }; + let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); + if !plot.ok() { + return; + } + let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; + let cat_len = if horizontal { plot.h } else { plot.w }; + let cw = cat_len / ncat as f32; + let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; + let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; + + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; data.series.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + let nslots = slot_keys.len().max(1) as f32; + let bw = cw / (nslots + 1.5); + let labels_on = chart.data_labels && ncat <= MAX_LABELS; + + // Areas first, then columns, then lines on top. + for pass in 0..3 { + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let want = match pass { + 0 => matches!(fam, Fam::Area | Fam::AreaStack), + 1 => is_col(fam), + _ => is_line(fam), + }; + if !want { + continue; + } + let sc = scale_of(i); + let fmt = Fmt::new(&s.number_format); + let row = match spans.get(i) { + Some(r) => r, + None => continue, + }; + match pass { + 0 => { + let mut top = Vec::with_capacity(ncat * 2); + let mut base = Vec::with_capacity(ncat); + for (j, sp) in row.iter().enumerate() { + let (b, t) = sp.unwrap_or((0.0, 0.0)); + let c = center(j); + top.push([c, vpos(t, &sc)]); + base.push([c, vpos(b, &sc)]); + } + if top.len() == 1 { + // A single point: give it some width. + if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { + top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; + base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; + } + } + let top = decimate(top, MAX_LINE_PTS * 2); + let base = decimate(base, MAX_LINE_PTS * 2); + let mut poly = top; + poly.extend(base.into_iter().rev()); + if poly.len() >= 3 { + ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); + } + } + 1 => { + let k = slot_of.get(i).copied().unwrap_or(0) as f32; + for (j, sp) in row.iter().enumerate() { + let Some((b, t)) = sp else { continue }; + let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); + let c = center(j); + let lo = pb.min(pt); + let len = (pb - pt).abs(); + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; + ctx.rect(lo, y, len, bw, s.color, None); + } else { + let x = c - nslots * bw / 2.0 + k * bw; + ctx.rect(x, lo, bw, len, s.color, None); + } + if labels_on && let Some(v) = s.values.get(j).copied().flatten() { + let t = fmt.format(v, None); + let stacked = fam != Fam::Col; + let mid = (pb + pt) / 2.0; + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; + if stacked { + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); + } else { + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } else { + let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; + if stacked { + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } else { + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); + } + } + } + } + } + _ => { + let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); + draw_runs(ctx, &pts, s.color, 2.25); + let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); + if k == ChartKind::LineMarkers && cw >= 3.0 { + draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); + } + if labels_on { + for (j, p) in pts.iter().enumerate() { + if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- scatter & bubble + +fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let pts_of = |s: &SeriesData| -> Vec> { + s.values + .iter() + .enumerate() + .map(|(j, v)| { + let x = match &s.x { + Some(xs) => xs.get(j).copied().flatten(), + None => Some((j + 1) as f64), + }; + match (x, v) { + (Some(x), Some(y)) => Some((x, *y)), + _ => None, + } + }) + .collect() + }; + let all: Vec>> = data.series.iter().map(pts_of).collect(); + let Some((xlo, xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; + let xs = nice_scale(xlo, xhi, false); + let ys = nice_scale(ylo, yhi, false); + let fx = Fmt::new("General"); + let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; + let bubble = chart.kind == ChartKind::Bubble; + let rmax = plot.w.min(plot.h) * 0.12; + for (s, pts) in data.series.iter().zip(&all) { + let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); + if bubble { + let sizes = s.sizes.as_deref().unwrap_or(&[]); + let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); + let mut bubbles: Vec<([f32; 2], f64)> = px + .iter() + .enumerate() + .filter_map(|(j, p)| { + let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; + match (p, sz) { + (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), + _ => None, + } + }) + .take(MAX_MARKERS) + .collect(); + bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); + let smax = if sizes.is_empty() { 1.0 } else { smax }; + let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; + for (p, sz) in bubbles { + let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); + } + } else { + let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; + if lines { + draw_runs(ctx, &px, s.color, 2.0); + } + if !lines || px.len() <= 500 { + draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); + } + } + if chart.data_labels && pts.len() <= MAX_LABELS { + for (p, raw) in px.iter().zip(pts) { + if let (Some(p), Some((_, y))) = (p, raw) { + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } +} + +// ---------------------------------------------------------------- histogram + +fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().flatten().copied().collect(); + let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; + let n = vals.len(); + let span = hi - lo; + let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); + if !(span.is_finite() && span > 0.0) { + bins = 1; + } + let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + let mut counts = vec![0usize; bins]; + for v in &vals { + let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 }; + let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 }; + if let Some(c) = counts.get_mut(b) { + *c += 1; + } + } + let f = Fmt::new(&s.number_format); + let labels: Vec = (0..bins) + .map(|b| { + let a = lo + b as f64 * width; + let e = if b + 1 == bins { hi.max(a) } else { a + width }; + let open = if b == 0 { '[' } else { '(' }; + format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) + }) + .collect(); + let maxc = counts.iter().copied().max().unwrap_or(0) as f64; + let ys = nice_scale(0.0, maxc, true); + let fc = Fmt::new("General"); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / bins as f32; + for (b, c) in counts.iter().enumerate() { + let top = plot.bottom() - ys.frac(*c as f64) * plot.h; + let x = plot.x + b as f32 * cw; + ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); + if chart.data_labels { + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- waterfall + +fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); + if ncat == 0 { + return; + } + let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); + let mut is_total = vec![false; ncat]; + let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); + if let Some(t) = is_total.last_mut() { + *t = last_total; + } + if !last_total { + labels.push("Total".into()); + vals.push(0.0); + is_total.push(true); + ncat += 1; + } + let mut bars = Vec::with_capacity(ncat); + let mut cum = 0.0f64; + for (j, v) in vals.iter().enumerate() { + if is_total.get(j).copied().unwrap_or(false) { + let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; + bars.push((0.0, total, 0u8)); + cum = total; + } else { + let top = clean(cum + v); + bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); + cum = top; + } + } + let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); + let ys = nice_scale(lo, hi, true); + let f = Fmt::new(&s.number_format); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / ncat as f32; + let bw = cw / 1.5; + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (j, (b, t, kind)) in bars.iter().enumerate() { + let color = match kind { + 1 => UP, + 2 => DOWN, + _ => s.color, + }; + let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; + let (yb, yt) = (vy(*b), vy(*t)); + ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); + if j + 1 < bars.len() && ncat <= 500 { + ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); + } + if chart.data_labels && ncat <= MAX_LABELS { + let v = if *kind == 0 { *t } else { t - b }; + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- funnel + +fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let n = data.categories.len().max(s.values.len()).min(MAX_CATS); + if n == 0 { + return; + } + let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); + let max = vals.iter().copied().fold(0.0f64, f64::max); + if max.is_nan() || max <= 0.0 { + return; + } + let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); + let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; + if !plot.ok() { + return; + } + let slot = plot.h / n as f32; + let bh = slot * 0.85; + let f = Fmt::new(&s.number_format); + let show = slot >= LABEL_SIZE * 0.9; + for (j, v) in vals.iter().enumerate() { + let bw = (*v / max) as f32 * plot.w; + let x = plot.x + (plot.w - bw) / 2.0; + let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; + if bw > 0.0 { + ctx.rect(x, y, bw, bh, s.color, None); + } + if show { + let yc = y + bh / 2.0; + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } +} + +// ---------------------------------------------------------------- pie, doughnut, sunburst + +fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let ri = r * inner; + let f = Fmt::new(&s.number_format); + let mut a = 0.0f32; + let mut labels = Vec::new(); + for (j, v) in vals.iter().enumerate() { + let sweep = (*v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + if (chart.data_labels || names) && vals.len() <= MAX_LABELS { + let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; + let mid = a + sweep / 2.0; + let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; + let room = sweep * rr; + if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + } +} + +// ---------------------------------------------------------------- treemap + +fn worst(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut items: Vec<(usize, f64)> = + s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); + items.sort_by(|a, b| b.1.total_cmp(&a.1)); + let f = Fmt::new(&s.number_format); + for (j, b) in squarify(&items, area) { + let fill = point_color(data, j); + ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); + if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { + let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + if chart.data_labels && b.h > LABEL_SIZE * 3.2 { + let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + } + } + } +} + +// ---------------------------------------------------------------- radar + +fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); + let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); + if r < 5.0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); + let sc = nice_scale(lo, hi, true); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let ang = |j: usize| j as f32 / ncat as f32 * TAU; + let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; + let ticks = sc.ticks(); + let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; + let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); + for (ti, t) in ticks.into_iter().enumerate() { + let rr = sc.frac(t) * r; + if rr > 0.5 && chart.gridlines { + let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); + if ncat < 3 { + ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); + } else if let Some(p) = ring.first().copied() { + ring.push(p); + } + ctx.line(ring, GRID, 0.75); + } + if ti % every == 0 { + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); + } + } + let step = ncat.div_ceil(72).max(1); + for j in (0..ncat).step_by(step) { + let a = ang(j); + ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); + let p = pt(a, r + 6.0); + let (sa, ca) = (a.sin(), a.cos()); + let align = if sa > 0.1 { + HAlign::Left + } else if sa < -0.1 { + HAlign::Right + } else { + HAlign::Center + }; + let valign = if ca > 0.1 { + VAlign::Bottom + } else if ca < -0.1 { + VAlign::Top + } else { + VAlign::Middle + }; + let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); + } + for s in &data.series { + let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); + if let Some(p) = pts.first().copied() { + pts.push(p); + } + ctx.line(pts, s.color, 2.0); + } +} + +// ---------------------------------------------------------------- box & whisker + +fn quantile(sorted: &[f64], q: f64) -> f64 { + let n = sorted.len(); + if n == 0 { + return 0.0; + } + let pos = q * (n - 1) as f64; + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = sorted.get(i).copied().unwrap_or(0.0); + let b = sorted.get(i + 1).copied().unwrap_or(a); + a + (b - a) * frac +} + +fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + if !plot.ok() { + return; + } + let n = data.series.len().max(1); + let cw = plot.w / n as f32; + let bw = (cw * 0.4).min(80.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (i, s) in data.series.iter().enumerate() { + let mut v: Vec = s.values.iter().flatten().copied().collect(); + if v.is_empty() { + continue; + } + v.sort_by(f64::total_cmp); + let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75)); + let iqr = q3 - q1; + let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); + let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); + let mean = v.iter().sum::() / v.len() as f64; + let c = plot.x + (i as f32 + 0.5) * cw; + ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); + ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); + let (yt, yb) = (vy(q3), vy(q1)); + ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); + ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); + let (mx, my, d) = (c, vy(mean), 3.0); + ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); + ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); + for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { + ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); + } + } +} + +// ---------------------------------------------------------------- stock + +fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + if !plot.ok() { + return; + } + let ser = |k: usize| data.series.get(k); + let (open, high, low, close) = match data.series.len() { + 0 => return, + 1 => (None, None, None, ser(0)), + 2 => (None, ser(0), ser(1), None), + 3 => (None, ser(0), ser(1), ser(2)), + _ => (ser(0), ser(1), ser(2), ser(3)), + }; + let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); + let cw = plot.w / ncat as f32; + let tick = (cw * 0.25).max(1.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for j in 0..ncat { + let c = plot.x + (j as f32 + 0.5) * cw; + if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { + ctx.vline(c, vy(h), vy(l), STOCK, 1.25); + } + match (get(open, j), get(close, j)) { + (Some(o), Some(cl)) => { + let (yo, yc) = (vy(o), vy(cl)); + let bw = (cw * 0.5).max(1.0); + let fill = if cl >= o { WHITE } else { STOCK }; + ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); + } + (None, Some(cl)) => { + if high.is_some() { + ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); + } else { + ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); + } + } + _ => {} + } + } +} + +// ---------------------------------------------------------------- final safety pass + +fn fin(v: f32) -> bool { + v.is_finite() +} + +fn clamp_stroke(s: Option<(Rgba, f32)>) -> Option<(Rgba, f32)> { + s.filter(|(_, w)| fin(*w) && *w > 0.0).map(|(c, w)| (c, w.min(50.0))) +} + +/// Drops non-finite primitives and clamps every coordinate into the `w`×`h` box. +pub(crate) fn finalize(prims: Vec, w: f32, h: f32) -> Vec { + let cx = |v: f32| v.clamp(0.0, w); + let cy = |v: f32| v.clamp(0.0, h); + let pts_ok = |pts: &[[f32; 2]]| pts.iter().all(|p| fin(p[0]) && fin(p[1])); + prims + .into_iter() + .filter_map(|p| match p { + Prim::Rect { x, y, w: rw, h: rh, fill, stroke, radius } => { + if !(fin(x) && fin(y) && fin(rw) && fin(rh)) { + return None; + } + let (x0, x1) = (cx(x.min(x + rw)), cx(x.max(x + rw))); + let (y0, y1) = (cy(y.min(y + rh)), cy(y.max(y + rh))); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + let radius = if fin(radius) { radius.clamp(0.0, (x1 - x0).min(y1 - y0) / 2.0) } else { 0.0 }; + Some(Prim::Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0, fill, stroke: clamp_stroke(stroke), radius }) + } + Prim::Line { pts, color, width, dash } => { + if !pts_ok(&pts) || pts.len() < 2 || !fin(width) { + return None; + } + let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect(); + Some(Prim::Line { pts, color, width: width.clamp(0.1, 50.0), dash }) + } + Prim::Polygon { pts, fill, stroke } => { + if !pts_ok(&pts) || pts.len() < 3 { + return None; + } + let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect(); + Some(Prim::Polygon { pts, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Wedge { cx: x, cy: y, r_outer, r_inner, a0, a1, fill, stroke } => { + if ![x, y, r_outer, r_inner, a0, a1].iter().all(|v| fin(*v)) { + return None; + } + let (x, y) = (cx(x), cy(y)); + let maxr = x.min(w - x).min(y).min(h - y); + let ro = r_outer.min(maxr); + if ro <= 0.0 { + return None; + } + Some(Prim::Wedge { cx: x, cy: y, r_outer: ro, r_inner: r_inner.clamp(0.0, ro), a0, a1, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Circle { cx: x, cy: y, r, fill, stroke } => { + if !(fin(x) && fin(y) && fin(r)) { + return None; + } + let (x, y) = (cx(x), cy(y)); + let r = r.min(x.min(w - x).min(y).min(h - y)); + if r <= 0.0 { + return None; + } + Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { + if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() { + return None; + } + Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation }) + } + }) + .collect() +} diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs new file mode 100644 index 000000000..5b4049758 --- /dev/null +++ b/crates/chart/src/resolve.rs @@ -0,0 +1,173 @@ +//! Pulls chart data (names, categories, values, formats, colours) out of a workbook. + +use sheetcraft_calc::evaluate; +use sheetcraft_core::{CellRef, RangeRef, Value, number_to_text}; +use sheetcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint}; +use sheetcraft_numfmt::{NumberFormat, format_value}; + +use crate::{ChartData, Rgba, SeriesData}; + +/// Cap on points per series (keeps whole-column references from exploding). +pub(crate) const MAX_POINTS: usize = 100_000; +/// Cap on series per chart. +pub(crate) const MAX_SERIES: usize = 255; + +/// Tints used for series beyond the six theme accents: lighter, darker, lighter still… +const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; + +/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. +pub fn series_color(theme: &Theme, i: usize) -> Rgba { + let accent = i % 6; + let cycle = (i / 6) % CYCLE_TINTS.len(); + let base = theme.colors.get(4 + accent).copied().unwrap_or(0x156082); + let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8]; + let tint = CYCLE_TINTS.get(cycle).copied().unwrap_or(0.0); + let [r, g, b] = apply_tint(rgb, tint); + [r, g, b, 0xFF] +} + +/// The first `n` series colours for `theme`. +pub fn default_palette(theme: &Theme, n: usize) -> Vec { + (0..n.min(4096)).map(|i| series_color(theme, i)).collect() +} + +/// Splits `Sheet!A1:B2` / `'My Sheet'!$A$1` / `A1:B2` into a sheet index and range. +fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, RangeRef)> { + let f = formula.trim().trim_start_matches('=').trim(); + let (sh, addr) = match f.rfind('!') { + Some(i) => { + let name = f.get(..i)?.trim(); + let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string()); + (wb.sheet_index(&name)?, f.get(i + 1..)?) + } + None => (sheet, f), + }; + let r = RangeRef::parse(addr)?; + Some((sh, r)) +} + +/// Evaluates a series formula to a flat list of values (row-major), capped. +fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec { + let f = formula.trim(); + if f.is_empty() { + return vec![]; + } + match evaluate(wb, sheet, CellRef::default(), f) { + Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(), + v => vec![v], + } +} + +fn to_num(v: &Value) -> Option { + match v { + Value::Number(n) if n.is_finite() => Some(*n), + _ => None, + } +} + +fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec> { + eval_list(wb, sheet, formula).iter().map(to_num).collect() +} + +/// Number format code of the first cell of a reference ("General" when not a reference). +fn ref_format(wb: &Workbook, sheet: usize, formula: &str) -> String { + parse_ref(wb, sheet, formula) + .and_then(|(sh, r)| wb.sheet(sh).map(|s| wb.styles.get(s.style_id(r.start)).num_fmt.0.to_string())) + .unwrap_or_else(|| "General".into()) +} + +fn value_text(v: &Value) -> String { + match v { + Value::Empty => String::new(), + Value::Number(n) => number_to_text(*n), + Value::Text(t) => t.to_string(), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(), + Value::Error(e) => e.as_str().into(), + Value::Array(a) => a.get(0, 0).map(value_text).unwrap_or_default(), + } +} + +/// Category labels, formatted with each source cell's number format (dates, currency…). +fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { + let vals = eval_list(wb, sheet, formula); + let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula); + let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); + vals.iter() + .enumerate() + .map(|(i, v)| { + if let (Some((s, r)), Value::Number(_)) = (src, v) { + let w = r.width().max(1) as usize; + let at = r.start.offset((i / w) as i64, (i % w) as i64); + if let Some(c) = at { + let code = &wb.styles.get(s.style_id(c)).num_fmt.0; + if !code.eq_ignore_ascii_case("general") { + return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + } + } + } + value_text(v) + }) + .collect() +} + +/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. +fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { + let t = name.trim(); + if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { + return q.replace("\"\"", "\""); + } + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') { + let vals = eval_list(wb, sheet, t); + let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); + return parts.join(" "); + } + t.to_string() +} + +fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { + match (chart_kind, series_kind) { + (ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k, + (ChartKind::Combo, _) => { + if i == 0 { + ChartKind::ColumnClustered + } else { + ChartKind::Line + } + } + (_, Some(k)) if k != ChartKind::Combo => k, + (k, _) => k, + } +} + +/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X +/// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the +/// default palette derived from the workbook theme accents). +pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { + let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble); + let mut series = Vec::new(); + let mut categories: Option> = None; + for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() { + let values = nums(wb, sheet, &s.values); + let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1)); + let color = + s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i)); + let kind = effective_kind(chart.kind, s.kind, i); + let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) { + (Some(f), true) => Some(nums(wb, sheet, f)), + _ => None, + }; + if categories.is_none() + && let Some(f) = &s.categories + { + let c = category_texts(wb, sheet, f); + if !c.is_empty() { + categories = Some(c); + } + } + let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f)); + series.push(SeriesData { name, values, x, sizes, color, kind, secondary: s.secondary, number_format: ref_format(wb, sheet, &s.values) }); + } + let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); + let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); + ChartData { categories, series } +} diff --git a/crates/chart/src/sparkline.rs b/crates/chart/src/sparkline.rs new file mode 100644 index 000000000..5a151ada8 --- /dev/null +++ b/crates/chart/src/sparkline.rs @@ -0,0 +1,82 @@ +//! In-cell sparklines. + +use sheetcraft_model::SparklineKind; + +use crate::render::{DOWN, finalize}; +use crate::resolve::MAX_POINTS; +use crate::{Prim, Rgba}; + +/// Draws a sparkline into a `w`×`h` box at the origin. Gaps (`None`) break lines; column and +/// win/loss sparklines draw negatives in our "down" red. +pub fn render_sparkline(kind: SparklineKind, values: &[Option], color: Rgba, markers: bool, w: f32, h: f32) -> Vec { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e6 || h > 1e6 { + return vec![]; + } + let vals: Vec> = values.iter().take(MAX_POINTS).map(|v| v.filter(|x| x.is_finite()).map(|x| x.clamp(-1e300, 1e300))).collect(); + let n = vals.len(); + if n == 0 { + return vec![]; + } + let pad = (h * 0.1).clamp(0.5, 2.0); + let (x0, y0, iw, ih) = (pad, pad, (w - 2.0 * pad).max(0.5), (h - 2.0 * pad).max(0.5)); + let mut out = Vec::new(); + let (lo, hi) = vals.iter().flatten().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v))); + if !lo.is_finite() { + return out; + } + match kind { + SparklineKind::Line => { + let span = hi - lo; + let fy = |v: f64| { + let f = if span > 0.0 { ((v - lo) / span) as f32 } else { 0.5 }; + y0 + ih - f * ih + }; + let fx = |j: usize| if n > 1 { x0 + j as f32 / (n - 1) as f32 * iw } else { x0 + iw / 2.0 }; + let mut run: Vec<[f32; 2]> = Vec::new(); + let flush = |run: &mut Vec<[f32; 2]>, out: &mut Vec| { + if run.len() >= 2 { + let pts = if run.len() > 4000 { run.iter().step_by(run.len() / 2000 + 1).copied().collect() } else { std::mem::take(run) }; + out.push(Prim::Line { pts, color, width: 1.25, dash: false }); + } + run.clear(); + }; + for (j, v) in vals.iter().enumerate() { + match v { + Some(v) => run.push([fx(j), fy(*v)]), + None => flush(&mut run, &mut out), + } + } + flush(&mut run, &mut out); + if markers || n == 1 { + let r = (h * 0.08).clamp(1.0, 3.0); + let step = (n / 500).max(1); + for (j, v) in vals.iter().enumerate().step_by(step) { + if let Some(v) = v { + out.push(Prim::Circle { cx: fx(j), cy: fy(*v), r, fill: Some(color), stroke: None }); + } + } + } + } + SparklineKind::Column | SparklineKind::WinLoss => { + let slot = iw / n as f32; + let bw = (slot * 0.8).max(0.25); + let win = kind == SparklineKind::WinLoss; + let (lo0, hi0) = if win { (-1.0, 1.0) } else { (lo.min(0.0), hi.max(0.0)) }; + let span = hi0 - lo0; + let fy = |v: f64| if span > 0.0 { y0 + ih - ((v - lo0) / span) as f32 * ih } else { y0 + ih }; + let base = fy(0.0); + for (j, v) in vals.iter().enumerate() { + let Some(v) = v else { continue }; + let v = if win { v.signum() * (*v != 0.0) as i32 as f64 } else { *v }; + if v == 0.0 && win { + continue; + } + let top = fy(v); + let (y, hh) = (top.min(base), (top - base).abs().max(0.5)); + let fill = if v < 0.0 { DOWN } else { color }; + out.push(Prim::Rect { x: x0 + j as f32 * slot + (slot - bw) / 2.0, y, w: bw, h: hh, fill: Some(fill), stroke: None, radius: 0.0 }); + } + } + } + finalize(out, w, h) +} diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs new file mode 100644 index 000000000..1f05fbe7b --- /dev/null +++ b/crates/chart/src/tests.rs @@ -0,0 +1,569 @@ +use std::sync::Arc; + +use sheetcraft_core::{CellRef, Value}; +use sheetcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, SparklineKind, Style, Workbook}; + +use crate::*; + +const ALL_KINDS: [ChartKind; 25] = [ + ChartKind::ColumnClustered, + ChartKind::ColumnStacked, + ChartKind::ColumnStacked100, + ChartKind::BarClustered, + ChartKind::BarStacked, + ChartKind::BarStacked100, + ChartKind::Line, + ChartKind::LineMarkers, + ChartKind::LineStacked, + ChartKind::Pie, + ChartKind::Doughnut, + ChartKind::Area, + ChartKind::AreaStacked, + ChartKind::Scatter, + ChartKind::ScatterLines, + ChartKind::Bubble, + ChartKind::Radar, + ChartKind::Histogram, + ChartKind::Waterfall, + ChartKind::Funnel, + ChartKind::Treemap, + ChartKind::Sunburst, + ChartKind::BoxWhisker, + ChartKind::Stock, + ChartKind::Combo, +]; + +fn chart(kind: ChartKind) -> Chart { + Chart { + id: 1, + kind, + anchor: Anchor::default(), + title: Some("Quarterly Sales".into()), + series: vec![], + legend: LegendPos::Bottom, + data_labels: true, + gridlines: true, + style: 0, + x_title: Some("Quarter".into()), + y_title: Some("Revenue".into()), + source: None, + by_rows: false, + } +} + +fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesData { + SeriesData { + name: name.into(), + values: values.iter().map(|v| Some(*v)).collect(), + x: Some(values.iter().enumerate().map(|(i, _)| Some(i as f64 * 1.5 + 2.0)).collect()), + sizes: Some(values.iter().map(|v| Some(v.abs() + 1.0)).collect()), + color, + kind, + secondary: false, + number_format: "$#,##0".into(), + } +} + +fn sample(kind: ChartKind) -> ChartData { + let cats = ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6"]; + let k2 = if kind == ChartKind::Combo { ChartKind::Line } else { kind }; + let k1 = if kind == ChartKind::Combo { ChartKind::ColumnClustered } else { kind }; + let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); + b.secondary = kind == ChartKind::Combo; + ChartData { + categories: cats.iter().map(|s| s.to_string()).collect(), + series: vec![ + series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), + b, + series("South", &[9000.0, 11000.0, 8000.0, 15000.0, 21000.0, 17000.0], [25, 107, 36, 255], k2), + ], + } +} + +fn coords(p: &Prim) -> Vec { + match p { + Prim::Rect { x, y, w, h, .. } => vec![*x, *y, x + w, y + h], + Prim::Line { pts, width, .. } => pts.iter().flat_map(|p| [p[0], p[1]]).chain([*width]).collect(), + Prim::Polygon { pts, .. } => pts.iter().flat_map(|p| [p[0], p[1]]).collect(), + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, .. } => vec![*cx, *cy, *r_outer, *r_inner, *a0, *a1], + Prim::Circle { cx, cy, r, .. } => vec![*cx, *cy, *r], + Prim::Text { x, y, size, rotation, .. } => vec![*x, *y, *size, *rotation], + } +} + +fn assert_sane(prims: &[Prim], w: f32, h: f32) { + let eps = 0.01; + for p in prims { + assert!(coords(p).iter().all(|v| v.is_finite()), "non-finite: {p:?}"); + let inside = |x: f32, y: f32| x >= -eps && x <= w + eps && y >= -eps && y <= h + eps; + match p { + Prim::Rect { x, y, w: rw, h: rh, .. } => assert!(inside(*x, *y) && inside(x + rw, y + rh), "{p:?}"), + Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => assert!(pts.iter().all(|q| inside(q[0], q[1])), "{p:?}"), + Prim::Wedge { cx, cy, r_outer, .. } => assert!(inside(cx - r_outer, cy - r_outer) && inside(cx + r_outer, cy + r_outer), "{p:?}"), + Prim::Circle { cx, cy, r, .. } => assert!(inside(cx - r, cy - r) && inside(cx + r, cy + r), "{p:?}"), + Prim::Text { x, y, .. } => assert!(inside(*x, *y), "{p:?}"), + } + } +} + +fn texts(prims: &[Prim]) -> Vec<&str> { + prims + .iter() + .filter_map(|p| match p { + Prim::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +#[test] +fn every_kind_renders_sanely() { + let m = ApproxMeasure; + for kind in ALL_KINDS { + for legend in [LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right] { + let mut c = chart(kind); + c.legend = legend; + let d = sample(kind); + let (w, h) = (640.0, 400.0); + let prims = render(&c, &d, w, h, &m); + assert!(prims.len() > 3, "{kind:?}: too few prims"); + assert_sane(&prims, w, h); + let t = texts(&prims); + assert!(t.contains(&"Quarterly Sales"), "{kind:?}: no title"); + let entries = legend_entries(&c, &d); + for (name, _, _) in &entries { + assert!(t.contains(&name.as_str()), "{kind:?} {legend:?}: legend entry {name} missing"); + } + let n_shapes = prims.iter().filter(|p| !matches!(p, Prim::Text { .. })).count(); + assert!(n_shapes > 2, "{kind:?}: nothing drawn"); + } + } +} + +#[test] +fn legend_counts() { + let d = sample(ChartKind::ColumnClustered); + assert_eq!(legend_entries(&chart(ChartKind::ColumnClustered), &d).len(), 3); + assert_eq!(legend_entries(&chart(ChartKind::Pie), &d).len(), 6); + assert_eq!(legend_entries(&chart(ChartKind::Waterfall), &d).len(), 3); + assert_eq!(legend_entries(&chart(ChartKind::Histogram), &d).len(), 0); + let mut c = chart(ChartKind::Line); + c.legend = LegendPos::None; + assert!(legend_entries(&c, &d).is_empty()); +} + +#[test] +fn nice_axis() { + let s = nice_scale(0.0, 61870.0, true); + assert_eq!((s.min, s.max, s.step), (0.0, 70000.0, 10000.0)); + let s = nice_scale(0.0, 100.0, true); + assert_eq!((s.min, s.max, s.step), (0.0, 120.0, 20.0)); + let s = nice_scale(-4000.0, 31000.0, true); + assert!(s.min < -4000.0 && s.min >= -10000.0 && s.max >= 31000.0); + // All zero. + let s = nice_scale(0.0, 0.0, true); + assert_eq!((s.min, s.max), (0.0, 1.0)); + // Far from zero: no forced zero. + let s = nice_scale(50.0, 55.0, false); + assert!(s.min >= 45.0 && s.max <= 60.0, "{s:?}"); + // Close to zero: zero included. + assert_eq!(nice_scale(10.0, 90.0, false).min, 0.0); + // Negative only. + let s = nice_scale(-80.0, -10.0, false); + assert_eq!(s.max, 0.0); + assert!(s.min <= -80.0); + for (lo, hi) in [(0.0, 1e-300), (-1e308, 1e308), (f64::NAN, 5.0), (0.001, 0.0013), (3.0, 3.0), (-7.0, -7.0), (1e15, 1e15 + 1.0)] { + let s = nice_scale(lo, hi, false); + assert!(s.min.is_finite() && s.max.is_finite() && s.step.is_finite() && s.max > s.min && s.step > 0.0, "{lo} {hi} {s:?}"); + let n = s.ticks().len(); + assert!((2..=51).contains(&n)); + } + // Ticks count in 4..=10 for typical data. + for hi in [1.0, 7.0, 13.0, 99.0, 250.0, 1234.0, 61870.0, 999999.0] { + let n = nice_scale(0.0, hi, true).ticks().len(); + assert!((4..=10).contains(&n), "{hi}: {n}"); + } +} + +#[test] +fn axis_labels_use_number_format() { + assert_eq!(format_number(40000.0, "$#,##0", Some(10000.0)), "$40,000"); + assert_eq!(format_number(0.30000000000000004, "General", Some(0.1)), "0.3"); + assert_eq!(format_number(1e-17, "General", Some(0.2)), "0"); + let c = chart(ChartKind::ColumnClustered); + let prims = render(&c, &sample(ChartKind::ColumnClustered), 640.0, 400.0, &ApproxMeasure); + let t = texts(&prims); + assert!(t.contains(&"$70,000"), "{t:?}"); + assert!(t.contains(&"$40,000")); +} + +#[test] +fn resolve_from_workbook() { + let mut wb = Workbook::new(); + let cur = wb.styles.intern(Style { num_fmt: sheetcraft_model::NumFmt(Arc::from("$#,##0")), ..Style::default() }); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Sales".into(); + s.set_value(CellRef::new(0, 1), Value::from("Revenue")); + for (i, (q, v)) in [("Q1", 100.0), ("Q2", 250.0), ("Q3", 175.0)].iter().enumerate() { + let r = i as u32 + 1; + s.set_value(CellRef::new(r, 0), Value::from(*q)); + s.set_value(CellRef::new(r, 1), Value::Number(*v)); + s.set_style(CellRef::new(r, 1), cur); + } + s.set_value(CellRef::new(2, 2), Value::Number(5.0)); + } + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![ + Series { + name: Some("Sales!$B$1".into()), + categories: Some("Sales!$A$2:$A$4".into()), + values: "Sales!$B$2:$B$4".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }, + Series { + name: None, + categories: None, + values: "=Sales!$C$2:$C$4".into(), + bubble_sizes: None, + color: Some(Color::rgb(1, 2, 3)), + secondary: true, + kind: None, + }, + Series { + name: Some("Literal".into()), + categories: None, + values: "{1,2,3,4}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }, + ]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, vec!["Q1", "Q2", "Q3"]); + assert_eq!(d.series.len(), 3); + let s0 = &d.series[0]; + assert_eq!(s0.name, "Revenue"); + assert_eq!(s0.values, vec![Some(100.0), Some(250.0), Some(175.0)]); + assert_eq!(s0.number_format, "$#,##0"); + assert_eq!(s0.color, series_color(&wb.theme, 0)); + let t = wb.theme.colors[4]; + assert_eq!(s0.color, [(t >> 16) as u8, (t >> 8) as u8, t as u8, 255]); + let s1 = &d.series[1]; + assert_eq!(s1.name, "Series2"); + assert_eq!(s1.values, vec![None, Some(5.0), None]); + assert_eq!(s1.color, [1, 2, 3, 255]); + assert!(s1.secondary); + assert_eq!(d.series[2].name, "Literal"); + assert_eq!(d.series[2].values.len(), 4); + + // Default categories. + c.series.remove(0); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, vec!["1", "2", "3", "4"]); + + // Combo kinds and scatter X values. + let mut c = chart(ChartKind::Combo); + c.series = vec![ + Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: false, kind: None }, + Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: true, kind: None }, + ]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series[0].kind, ChartKind::ColumnClustered); + assert_eq!(d.series[1].kind, ChartKind::Line); + let mut c = chart(ChartKind::Bubble); + c.series = vec![Series { + name: None, + categories: Some("Sales!B2:B4".into()), + values: "Sales!B2:B4".into(), + bubble_sizes: Some("{1,2,3}".into()), + color: None, + secondary: false, + kind: None, + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3)); + assert_eq!(d.series[0].sizes, Some(vec![Some(1.0), Some(2.0), Some(3.0)])); + + // Bad formulas never panic. + let mut c = chart(ChartKind::Line); + c.series = vec![Series { + name: Some("=Nope!A1".into()), + categories: Some("((".into()), + values: "Missing!A1:A3".into(), + bubble_sizes: Some("".into()), + color: None, + secondary: false, + kind: None, + }]; + let d = resolve(&wb, 7, &c); + assert_eq!(d.series.len(), 1); + let _ = render(&c, &d, 300.0, 200.0, &ApproxMeasure); +} + +#[test] +fn palette_beyond_six_series() { + let theme = sheetcraft_model::Theme::default(); + let p = default_palette(&theme, 14); + assert_eq!(p.len(), 14); + assert_ne!(p[0], p[6]); + assert_ne!(p[6], p[12]); +} + +fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * w + x) * 4) as usize; + [img[i], img[i + 1], img[i + 2], img[i + 3]] +} + +#[test] +fn rasterize_bar_center_has_bar_color() { + let mut c = chart(ChartKind::ColumnClustered); + c.data_labels = false; + let d = sample(ChartKind::ColumnClustered); + let (w, h) = (640u32, 400u32); + let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure); + let img = rasterize(&prims, w, h, [255, 255, 255, 255]); + assert_eq!(img.len(), (w * h * 4) as usize); + // Find the bars of the East series (tallest) and check their centres. + let east = d.series[1].color; + let bars: Vec<_> = prims + .iter() + .filter_map(|p| match p { + Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == east && *w > 10.0 => Some((*x, *y, *w, *h)), + _ => None, + }) + .collect(); + assert_eq!(bars.len(), 6); + for (x, y, bw, bh) in bars { + let px = pixel(&img, w, (x + bw / 2.0) as u32, (y + bh / 2.0) as u32); + assert_eq!(px, east); + } + // Primitive-level checks. + let img = rasterize(&[Prim::Circle { cx: 10.0, cy: 10.0, r: 5.0, fill: Some([255, 0, 0, 255]), stroke: None }], 20, 20, [0, 0, 0, 0]); + assert_eq!(pixel(&img, 20, 10, 10), [255, 0, 0, 255]); + assert_eq!(pixel(&img, 20, 0, 0)[3], 0); + let wedge = Prim::Wedge { + cx: 50.0, + cy: 50.0, + r_outer: 40.0, + r_inner: 20.0, + a0: 0.0, + a1: std::f32::consts::FRAC_PI_2, + fill: [0, 0, 255, 255], + stroke: None, + }; + let img = rasterize(&[wedge], 100, 100, [255, 255, 255, 255]); + assert_eq!(pixel(&img, 100, 79, 30), [0, 0, 255, 255]); // upper-right quadrant ring + assert_eq!(pixel(&img, 100, 50, 50), [255, 255, 255, 255]); // hole + assert_eq!(pixel(&img, 100, 25, 70), [255, 255, 255, 255]); // other quadrant + assert!(rasterize(&[], 0, 10, [0; 4]).is_empty()); + assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty()); +} + +#[test] +fn pie_starts_at_twelve_clockwise() { + let c = chart(ChartKind::Pie); + let d = sample(ChartKind::Pie); + let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure); + let wedges: Vec<_> = prims + .iter() + .filter_map(|p| match p { + Prim::Wedge { a0, a1, r_inner, stroke, .. } => Some((*a0, *a1, *r_inner, *stroke)), + _ => None, + }) + .collect(); + assert_eq!(wedges.len(), 6); + assert_eq!(wedges[0].0, 0.0); + assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4)); + assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3); + assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4]))); + let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure); + let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3)); + assert!(ok); +} + +#[test] +fn negative_values_put_axis_below_zero() { + let c = chart(ChartKind::ColumnClustered); + let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); + let t = texts(&prims); + assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); + assert!(t.contains(&"1") && t.contains(&"3"), "default categories: {t:?}"); +} + +#[test] +fn edge_cases_never_panic() { + let m = ApproxMeasure; + let empty = ChartData::default(); + let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let weird = ChartData { + categories: vec![String::new(); 3], + series: vec![SeriesData { + name: "w".repeat(500), + values: vec![Some(f64::NAN), Some(f64::INFINITY), None, Some(-1e308), Some(1e308)], + x: Some(vec![Some(f64::NAN)]), + sizes: Some(vec![Some(-1.0), Some(0.0)]), + ..SeriesData::default() + }], + }; + for kind in ALL_KINDS { + let c = chart(kind); + for d in [&empty, &zeros, &weird] { + for (w, h) in [(0.0, 0.0), (-5.0, 100.0), (f32::NAN, 10.0), (1.0, 1.0), (20.0, 15.0), (640.0, 400.0), (5000.0, 30.0), (1e6, 1e6)] { + let p = render(&c, d, w, h, &m); + if !(w > 0.0 && h > 0.0) || w.is_nan() { + assert!(p.is_empty()); + } else { + assert_sane(&p, w, h); + } + } + } + } +} + +#[test] +fn thousands_of_points_are_decimated() { + let n = 50_000; + let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); + let mut c = chart(ChartKind::Line); + c.data_labels = true; + let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); + let max_line = prims + .iter() + .filter_map(|p| match p { + Prim::Line { pts, .. } => Some(pts.len()), + _ => None, + }) + .max() + .unwrap_or(0); + assert!(max_line > 10 && max_line <= 4 * 2000, "{max_line}"); + assert!(prims.len() < 10_000); + for kind in [ChartKind::Scatter, ChartKind::Histogram, ChartKind::AreaStacked, ChartKind::BoxWhisker] { + let p = render(&chart(kind), &d, 800.0, 400.0, &ApproxMeasure); + assert_sane(&p, 800.0, 400.0); + } +} + +/// Small deterministic PRNG (xorshift) for the fuzz test. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn f(&mut self) -> f64 { + (self.next() % 1_000_000) as f64 / 1_000_000.0 + } +} + +#[test] +fn fuzz_random_data_and_sizes() { + let mut r = Rng(0x9E37_79B9_7F4A_7C15); + let m = ApproxMeasure; + for iter in 0..400 { + let kind = ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize]; + let nser = (r.next() % 6) as usize; + let npts = (r.next() % 40) as usize; + let mag = 10f64.powi((r.next() % 30) as i32 - 10); + let val = |r: &mut Rng| -> Option { + match r.next() % 12 { + 0 => None, + 1 => Some(f64::NAN), + 2 => Some(0.0), + 3 => Some(-r.f() * mag), + _ => Some(r.f() * mag), + } + }; + let series: Vec = (0..nser) + .map(|i| SeriesData { + name: format!("S{i}"), + values: (0..npts).map(|_| val(&mut r)).collect(), + x: if r.next().is_multiple_of(2) { Some((0..npts).map(|_| val(&mut r)).collect()) } else { None }, + sizes: if r.next().is_multiple_of(2) { Some((0..npts).map(|_| val(&mut r)).collect()) } else { None }, + color: [(r.next() % 256) as u8, 100, 50, 255], + kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize], + secondary: r.next().is_multiple_of(3), + number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(), + }) + .collect(); + let ncat = (r.next() % 50) as usize; + let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let mut c = chart(kind); + c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; + c.data_labels = r.next().is_multiple_of(2); + c.gridlines = r.next().is_multiple_of(2); + if r.next().is_multiple_of(3) { + c.title = None; + } + let w = [0.0, -3.0, 1.0, 12.0, 80.0, 320.0, 1200.0][(r.next() % 7) as usize] as f32 + r.f() as f32 * 10.0; + let h = [0.0, 2.0, 30.0, 200.0, 900.0][(r.next() % 5) as usize] as f32 + r.f() as f32 * 10.0; + let p = render(&c, &d, w, h, &m); + if w >= 1.0 && h >= 1.0 { + assert_sane(&p, w, h); + } + if iter % 20 == 0 && w >= 1.0 && h >= 1.0 && w < 2000.0 && h < 2000.0 { + let img = rasterize(&p, w as u32, h as u32, [255; 4]); + assert_eq!(img.len(), (w as u32 * h as u32 * 4) as usize); + } + let vals: Vec> = (0..npts).map(|_| val(&mut r)).collect(); + for k in [SparklineKind::Line, SparklineKind::Column, SparklineKind::WinLoss] { + let sp = render_sparkline(k, &vals, [0, 0, 0, 255], r.next().is_multiple_of(2), w, h); + if w >= 1.0 && h >= 1.0 { + assert_sane(&sp, w.max(1.0), h.max(1.0)); + } + } + } +} + +#[test] +fn sparklines() { + let v = [Some(1.0), Some(-2.0), None, Some(4.0), Some(0.0), Some(3.0)]; + let line = render_sparkline(SparklineKind::Line, &v, [1, 2, 3, 255], true, 60.0, 16.0); + assert_eq!(line.iter().filter(|p| matches!(p, Prim::Line { .. })).count(), 2); + assert_eq!(line.iter().filter(|p| matches!(p, Prim::Circle { .. })).count(), 5); + assert_sane(&line, 60.0, 16.0); + let col = render_sparkline(SparklineKind::Column, &v, [1, 2, 3, 255], false, 60.0, 16.0); + assert_eq!(col.len(), 5); + assert_sane(&col, 60.0, 16.0); + let wl = render_sparkline(SparklineKind::WinLoss, &v, [1, 2, 3, 255], false, 60.0, 16.0); + assert_eq!(wl.len(), 4); + assert!(render_sparkline(SparklineKind::Line, &v, [0; 4], false, 0.0, 10.0).is_empty()); + assert!(render_sparkline(SparklineKind::Line, &[], [0; 4], false, 10.0, 10.0).is_empty()); +} + +#[test] +fn treemap_and_histogram_shapes() { + let d = ChartData { + categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], + series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], + }; + let mut c = chart(ChartKind::Treemap); + c.title = None; + c.legend = LegendPos::None; + let prims = render(&c, &d, 600.0, 400.0, &ApproxMeasure); + let rects: Vec = prims + .iter() + .skip(1) + .filter_map(|p| match p { + Prim::Rect { w, h, .. } => Some(w * h), + _ => None, + }) + .collect(); + assert_eq!(rects.len(), 4); + // Areas proportional to values. + assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); + + let vals: Vec = (0..100).map(|i| i as f64).collect(); + let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); + let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); + assert_eq!(bins, 10); +} diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 09540b620..7fba23fe5 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -144,7 +144,8 @@ pub fn time_fraction(h: f64, m: f64, s: f64) -> f64 { (h * 3600.0 + m * 60.0 + s) / 86400.0 } -pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +pub const MONTHS: [&str; 12] = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; #[cfg(test)] diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index babefb077..b08cf2aab 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -212,7 +212,9 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { let parts: Vec<&str> = t.split(seps).collect(); if parts.len() == 3 { let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim()); - if a.len() == 4 && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) { + if a.len() == 4 + && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) + { return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd")); } if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) { diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index b5bd430b4..bf8ae98b8 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -83,7 +83,11 @@ fn threshold(wb: &Workbook, sheet: usize, at: CellRef, st: &RuleStats, k: &CfVal fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { let t = t.clamp(0.0, 1.0); - [(a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as u8, (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as u8, (a[2] as f64 + (b[2] as f64 - a[2] as f64) * t).round() as u8] + [ + (a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as u8, + (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as u8, + (a[2] as f64 + (b[2] as f64 - a[2] as f64) * t).round() as u8, + ] } /// Number of icons in a set name like `3TrafficLights1`, `5Quarters`. @@ -104,7 +108,8 @@ impl CfCache { } let mut out = CfLook::default(); let mut any = false; - let mut rules: Vec<(usize, &CondFormat)> = sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect(); + let mut rules: Vec<(usize, &CondFormat)> = + sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect(); rules.sort_by_key(|(_, cf)| cf.priority); let v = sh.value(c); for (idx, cf) in rules { @@ -141,7 +146,9 @@ impl CfCache { }; (ok && !v.is_empty()).then_some(style.as_ref()) } - CfRule::Expression { formula, style } => sheetcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()), + CfRule::Expression { formula, style } => { + sheetcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) + } CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()), CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()), CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), @@ -150,13 +157,21 @@ impl CfCache { CfRule::NoBlanks { style } => (!v.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 } => (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()), - CfRule::Unique { style } => (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()), + CfRule::Duplicate { style } => { + (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) + } + CfRule::Unique { style } => { + (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) + } CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() { Some(n) if !st.sorted.is_empty() => { let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize }; let k = k.clamp(1, st.sorted.len()); - let ok = if *bottom { n <= st.sorted.get(k - 1).copied().unwrap_or(n) } else { n >= st.sorted.get(st.sorted.len() - k).copied().unwrap_or(n) }; + let ok = if *bottom { + n <= st.sorted.get(k - 1).copied().unwrap_or(n) + } else { + n >= st.sorted.get(st.sorted.len() - k).copied().unwrap_or(n) + }; ok.then_some(style.as_ref()) } _ => None, @@ -175,7 +190,8 @@ impl CfCache { let d = n.floor(); let wd = sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); let week_start = today - wd; - let (ty, tm) = sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); + let (ty, tm) = + sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); let ym = sheetcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month)); let month_off = |k: i32| { let m = tm as i32 + k; @@ -202,7 +218,10 @@ impl CfCache { }, CfRule::ColorScale { stops } => { if let Some(n) = v.as_f64() { - let pts: Vec<(f64, [u8; 3])> = stops.iter().map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))).collect(); + let pts: Vec<(f64, [u8; 3])> = stops + .iter() + .map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))) + .collect(); let rgb = match pts.as_slice() { [(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }), [(a, ca), (m, cm), (b, cb)] => { @@ -228,7 +247,11 @@ impl CfCache { let lo = if lo > 0.0 { 0.0 } else { lo }; let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 }; if out.bar.is_none() { - out.bar = Some(((frac as f32) * 0.9 + 0.1 * (frac > 0.0) as u8 as f32, color.resolve(&wb.theme).unwrap_or([0x63, 0x8E, 0xC6]), *gradient)); + out.bar = Some(( + (frac as f32) * 0.9 + 0.1 * (frac > 0.0) as u8 as f32, + color.resolve(&wb.theme).unwrap_or([0x63, 0x8E, 0xC6]), + *gradient, + )); out.hide_value |= !show_value; any = true; } @@ -238,7 +261,11 @@ 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() }; + 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() + }; let mut idx = cuts.iter().filter(|cut| n >= **cut).count(); if *reverse { idx = k - 1 - idx.min(k - 1); @@ -273,7 +300,11 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { return f.to_string(); } match sheetcraft_formula::parse(f.trim_start_matches('=')) { - Ok(e) => sheetcraft_formula::print(&sheetcraft_formula::adjust::shift_relative(e, c.row as i64 - first.row as i64, c.col as i64 - first.col as i64)), + Ok(e) => sheetcraft_formula::print(&sheetcraft_formula::adjust::shift_relative( + e, + c.row as i64 - first.row as i64, + c.col as i64 - first.col as i64, + )), Err(_) => f.to_string(), } } diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 5b602bd47..2182124b2 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -12,23 +12,81 @@ use crate::selection::current_region; pub fn specs() -> Vec { vec![ - cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort(s, p, true)), + cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort( + s, p, true + )), cmd!("data.sortDescending", "Sort Z to A", ["Data", "Sort & Filter"], None, "{range?, column?}", has_doc, |s, p| quick_sort(s, p, false)), - cmd!("data.sort", "Sort…", ["Data", "Sort & Filter"], None, "{range?, header?: bool, keys: [{column: \"B\", order: asc|desc, by?: values|cellColor|fontColor, customList?: [..]}], orientation?: rows|columns, matchCase?}", has_doc, sort), - cmd!("data.filter", "Filter", ["Data", "Sort & Filter"], Some("Cmd+Shift+F"), "{range?} toggles AutoFilter on the current region", has_doc, toggle_filter), - cmd!("data.filterBy", "Filter Column", [], None, "{column: \"B\" (or 0-based offset), values?: [..], blanks?: bool, custom?: {op: \">\", value: \"10\", op2?, value2?, and?}, top?: {count, bottom?, percent?}, aboveAverage?: bool, clear?: bool}", has_doc, filter_by), + cmd!( + "data.sort", + "Sort…", + ["Data", "Sort & Filter"], + None, + "{range?, header?: bool, keys: [{column: \"B\", order: asc|desc, by?: values|cellColor|fontColor, customList?: [..]}], orientation?: rows|columns, matchCase?}", + has_doc, + sort + ), + cmd!( + "data.filter", + "Filter", + ["Data", "Sort & Filter"], + Some("Cmd+Shift+F"), + "{range?} toggles AutoFilter on the current region", + has_doc, + toggle_filter + ), + cmd!( + "data.filterBy", + "Filter Column", + [], + None, + "{column: \"B\" (or 0-based offset), values?: [..], blanks?: bool, custom?: {op: \">\", value: \"10\", op2?, value2?, and?}, top?: {count, bottom?, percent?}, aboveAverage?: bool, clear?: bool}", + has_doc, + filter_by + ), cmd!("data.clearFilter", "Clear", ["Data", "Sort & Filter"], None, "{}", has_doc, clear_filter), cmd!("data.reapply", "Reapply", ["Data", "Sort & Filter"], None, "{}", has_doc, reapply), - cmd!("data.removeDuplicates", "Remove Duplicates", ["Data", "Data Tools"], None, "{range?, columns?: [\"A\",\"C\"], header?: bool}", has_doc, remove_duplicates), - cmd!("data.textToColumns", "Text to Columns", ["Data", "Data Tools"], None, "{range?, delimiters?: [\",\", \"\\t\", \" \", \";\"], other?: \"|\", fixedWidths?: [5, 10], treatConsecutive?: bool, textQualifier?: \"\\\"\", destination?: \"B1\"}", has_doc, text_to_columns), - cmd!("data.validation", "Data Validation…", ["Data", "Data Tools"], None, "{range?, type: any|whole|decimal|list|date|time|textLength|custom, operator?: between|notBetween|equal|notEqual|greater|less|greaterOrEqual|lessOrEqual, formula1, formula2?, ignoreBlank?, dropdown?, inputTitle?, inputMessage?, errorStyle?: stop|warning|information, errorTitle?, errorMessage?, clear?: bool}", has_doc, validation), + cmd!( + "data.removeDuplicates", + "Remove Duplicates", + ["Data", "Data Tools"], + None, + "{range?, columns?: [\"A\",\"C\"], header?: bool}", + has_doc, + remove_duplicates + ), + cmd!( + "data.textToColumns", + "Text to Columns", + ["Data", "Data Tools"], + None, + "{range?, delimiters?: [\",\", \"\\t\", \" \", \";\"], other?: \"|\", fixedWidths?: [5, 10], treatConsecutive?: bool, textQualifier?: \"\\\"\", destination?: \"B1\"}", + has_doc, + text_to_columns + ), + cmd!( + "data.validation", + "Data Validation…", + ["Data", "Data Tools"], + None, + "{range?, type: any|whole|decimal|list|date|time|textLength|custom, operator?: between|notBetween|equal|notEqual|greater|less|greaterOrEqual|lessOrEqual, formula1, formula2?, ignoreBlank?, dropdown?, inputTitle?, inputMessage?, errorStyle?: stop|warning|information, errorTitle?, errorMessage?, clear?: bool}", + has_doc, + validation + ), cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd), cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid), cmd!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)), cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)), cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)), cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)), - cmd!("data.subtotal", "Subtotal", ["Data", "Outline"], None, "{range?, groupBy: \"A\", function?: sum|count|average|max|min|product, columns: [\"C\"], header?: true}", has_doc, subtotal), + cmd!( + "data.subtotal", + "Subtotal", + ["Data", "Outline"], + None, + "{range?, groupBy: \"A\", function?: sum|count|average|max|min|product, columns: [\"C\"], header?: true}", + has_doc, + subtotal + ), cmd!("data.flashFill", "Flash Fill", ["Data", "Data Tools"], None, "{range?}", has_doc, |s, p| s.execute("edit.flashFill", p.clone())), ] } @@ -75,7 +133,9 @@ fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { if a.is_text() && !b.is_text() && !b.is_empty() { votes += 1; } - if wb.styles.get(sh.style_id(CellRef::new(r.start.row, c))).font.bold != wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).font.bold { + if wb.styles.get(sh.style_id(CellRef::new(r.start.row, c))).font.bold + != wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).font.bold + { votes += 1; } if a.is_text() && b.is_text() { @@ -165,8 +225,12 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co let c = line(i, k.col); let v = sh.value(c); let color = match k.by.as_str() { - "cellColor" => styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX), - "fontColor" => styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX), + "cellColor" => { + styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + "fontColor" => { + styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } _ => 0, }; (v, color) @@ -217,7 +281,8 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co } } // Row heights travel with rows. - let heights: Vec> = if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + let heights: Vec> = + if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; let shm = cx.sheet_mut(sheet)?; for (c, cell) in &moved { match cell { @@ -254,7 +319,8 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co fn toggle_filter(s: &mut Session, p: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet; let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone())); + let in_table = + s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone())); if let Some(name) = in_table { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; @@ -300,7 +366,10 @@ fn filter_by(s: &mut Session, p: &Json) -> Result { let crit = if bool_param(p, "clear").unwrap_or(false) { None } else if let Some(vals) = p.get("values").and_then(Json::as_array) { - Some(FilterCriterion::Values { values: vals.iter().map(super::edit::json_to_input).collect(), blanks: bool_param(p, "blanks").unwrap_or(false) }) + Some(FilterCriterion::Values { + values: vals.iter().map(super::edit::json_to_input).collect(), + blanks: bool_param(p, "blanks").unwrap_or(false), + }) } else if let Some(c) = p.get("custom") { let a = (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(super::edit::json_to_input).unwrap_or_default()); let b = c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(super::edit::json_to_input).unwrap_or_default())); @@ -339,8 +408,20 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { }; let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); match op { - "=" => if crit.contains(['*', '?']) { wild() } else { cmp == Some(Ordering::Equal) }, - "<>" => if crit.contains(['*', '?']) { !wild() } else { cmp != Some(Ordering::Equal) }, + "=" => { + if crit.contains(['*', '?']) { + wild() + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if crit.contains(['*', '?']) { + !wild() + } else { + cmp != Some(Ordering::Equal) + } + } ">" => cmp == Some(Ordering::Greater), "<" => cmp == Some(Ordering::Less), ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), @@ -405,7 +486,13 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { _ => false, }, FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) { - (Some(n), Some((_, avg))) => if *above { n > *avg } else { n < *avg }, + (Some(n), Some((_, avg))) => { + if *above { + n > *avg + } else { + n < *avg + } + } _ => false, }, FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, @@ -493,7 +580,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let cols: Vec = p.get("columns").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()).unwrap_or_else(|| (r.start.col..=r.end.col).collect()); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); let sheet = d.wb.active_sheet; let start = r.start.row + header as u32; let mut removed = 0; @@ -509,7 +600,8 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { removed += 1; } } - let rows: Vec>> = keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); + let rows: Vec>> = + keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); let shm = cx.sheet_mut(sheet)?; for row in start..=r.end.row { for c in r.start.col..=r.end.col { @@ -535,11 +627,16 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { fn text_to_columns(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let sheet = target_sheet(s, p)?; - let mut delims: Vec = p.get("delimiters").and_then(Json::as_array).map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()).unwrap_or_else(|| vec!['\t']); + let mut delims: Vec = p + .get("delimiters") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) + .unwrap_or_else(|| vec!['\t']); if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { delims.push(o); } - let fixed: Option> = p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); + let fixed: Option> = + p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); let dest = cell_param(p, "destination").unwrap_or(r.start); @@ -554,7 +651,9 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { let mut cuts = vec![0]; cuts.extend(w.iter().copied().filter(|x| *x < chars.len())); cuts.push(chars.len()); - cuts.windows(2).map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()).collect() + cuts.windows(2) + .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) + .collect() } None => split_delimited(&text, &delims, consecutive, quote), }; @@ -680,7 +779,12 @@ fn validation(s: &mut Session, p: &Json) -> Result { /// List entries of a list validation (literal or from a range). pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { let f = dv.f1.trim(); - if f.starts_with('"') || (!f.contains('!') && sheetcraft_formula::parse(f).map(|e| !matches!(e, sheetcraft_formula::Expr::Ref(_) | sheetcraft_formula::Expr::Name(_))).unwrap_or(true)) { + if f.starts_with('"') + || (!f.contains('!') + && sheetcraft_formula::parse(f) + .map(|e| !matches!(e, sheetcraft_formula::Expr::Ref(_) | sheetcraft_formula::Expr::Name(_))) + .unwrap_or(true)) + { return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); @@ -724,7 +828,11 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - if ok { return None; } - let msg = if dv.error_message.is_empty() { "This value doesn't match the data validation restrictions defined for this cell.".to_string() } else { dv.error_message.clone() }; + let msg = if dv.error_message.is_empty() { + "This value doesn't match the data validation restrictions defined for this cell.".to_string() + } else { + dv.error_message.clone() + }; Some((dv.clone(), msg)) } @@ -782,11 +890,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { return Ok((false, r.start.col, r.end.col)); } let r = s.doc()?.selection.current(); - if r.is_full_cols() && !r.is_full_rows() { - Ok((false, r.start.col, r.end.col)) - } else { - Ok((true, r.start.row, r.end.row)) - } + if r.is_full_cols() && !r.is_full_rows() { Ok((false, r.start.col, r.end.col)) } else { Ok((true, r.start.row, r.end.row)) } } fn group(s: &mut Session, p: &Json, on: bool) -> Result { @@ -847,7 +951,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { "product" => 6, _ => 9, }; - let cols: Vec = p.get("columns").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()).unwrap_or_else(|| vec![r.end.col]); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| vec![r.end.col]); let label = match func { "count" => "Count", "average" => "Average", diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 72f1ec22d..6208a542b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,8 +11,24 @@ use crate::{Clipboard, Session}; pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), - cmd!("range.setValues", "Set Values", [], None, "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", has_doc, range_set_values), - cmd!("range.fill", "Fill Range With Input", [], Some("Ctrl+Enter"), "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", has_doc, range_fill), + cmd!( + "range.setValues", + "Set Values", + [], + None, + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + has_doc, + range_set_values + ), + cmd!( + "range.fill", + "Fill Range With Input", + [], + Some("Ctrl+Enter"), + "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", + has_doc, + range_fill + ), cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set), cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move), cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next), @@ -26,24 +42,76 @@ pub fn specs() -> Vec { cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy), cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), - cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}", has_doc, paste), - cmd!("edit.pasteSpecial", "Paste Special…", ["Home", "Clipboard"], Some("Ctrl+Cmd+V"), "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", has_clipboard, paste_special), + cmd!( + "edit.paste", + "Paste", + ["Home", "Clipboard"], + Some("Cmd+V"), + "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}", + has_doc, + paste + ), + cmd!( + "edit.pasteSpecial", + "Paste Special…", + ["Home", "Clipboard"], + Some("Ctrl+Cmd+V"), + "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", + has_clipboard, + paste_special + ), cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard), cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")), cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")), - cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear(s, p, "contents")), - cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "comments")), + cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear( + s, p, "contents" + )), + cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear( + s, p, "comments" + )), cmd!("edit.clearHyperlinks", "Clear Hyperlinks", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "hyperlinks")), cmd!("edit.fillDown", "Fill Down", ["Home", "Editing", "Fill"], Some("Cmd+D"), "{range?}", has_doc, |s, p| fill_dir(s, p, 1, 0)), cmd!("edit.fillRight", "Fill Right", ["Home", "Editing", "Fill"], Some("Cmd+R"), "{range?}", has_doc, |s, p| fill_dir(s, p, 0, 1)), cmd!("edit.fillUp", "Fill Up", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, -1, 0)), cmd!("edit.fillLeft", "Fill Left", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, 0, -1)), - cmd!("edit.autoFill", "AutoFill", [], None, "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag", has_doc, auto_fill), - cmd!("edit.fillSeries", "Series…", ["Home", "Editing", "Fill"], None, "{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}", has_doc, fill_series), + cmd!( + "edit.autoFill", + "AutoFill", + [], + None, + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag", + has_doc, + auto_fill + ), + cmd!( + "edit.fillSeries", + "Series…", + ["Home", "Editing", "Fill"], + None, + "{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}", + has_doc, + fill_series + ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill), cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), - cmd!("edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}", has_doc, replace), - cmd!("edit.formatPainter", "Format Painter", ["Home", "Clipboard"], None, "{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them", has_doc, format_painter), + cmd!( + "edit.replace", + "Replace…", + ["Home", "Editing", "Find & Select"], + Some("Ctrl+H"), + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}", + has_doc, + replace + ), + cmd!( + "edit.formatPainter", + "Format Painter", + ["Home", "Clipboard"], + None, + "{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them", + has_doc, + format_painter + ), cmd!(noundo "edit.beginEdit", "Edit Cell", [], Some("F2"), "{}", has_doc, begin_edit), ] } @@ -62,7 +130,11 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut sheetcraft if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } - let is_formula = input.starts_with('=') || (input.len() > 1 && (input.starts_with('+') || input.starts_with('-')) && sheetcraft_core::parse::parse_number_text(input).is_none() && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + let is_formula = input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && sheetcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); if is_formula { let body = input.strip_prefix('=').unwrap_or(input); // Excel closes missing parentheses for you. @@ -81,7 +153,12 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut sheetcraft // Formulas whose result is a date/time get a format from functions like TODAY(). if wb.styles.get(style).num_fmt.as_str() == "General" { let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); - let auto = if f.starts_with("TODAY(") || f.starts_with("DATE(") || f.starts_with("EDATE(") || f.starts_with("EOMONTH(") || f.starts_with("WORKDAY(") { + let auto = if f.starts_with("TODAY(") + || f.starts_with("DATE(") + || f.starts_with("EDATE(") + || f.starts_with("EOMONTH(") + || f.starts_with("WORKDAY(") + { Some("m/d/yyyy") } else if f.starts_with("NOW(") { Some("m/d/yyyy h:mm") @@ -117,9 +194,11 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); + let input = + str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); let array = bool_param(p, "array").unwrap_or(false); - let protected = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); + let protected = + s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); if protected { return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); } @@ -156,7 +235,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { pub(crate) fn json_to_input(v: &Json) -> String { match v { Json::Null => String::new(), - Json::Bool(b) => if *b { "TRUE".into() } else { "FALSE".into() }, + Json::Bool(b) => { + if *b { + "TRUE".into() + } else { + "FALSE".into() + } + } Json::Number(n) => n.to_string(), Json::String(s) => s.clone(), other => other.to_string(), @@ -182,7 +267,11 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { let input = json_to_input(v); let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { - Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), + Json::Number(n) => Some(Cell { + value: Value::number(n.as_f64().unwrap_or(0.0)), + formula: None, + style: old.as_ref().map(|c| c.style).unwrap_or_default(), + }), Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, }; @@ -309,9 +398,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { t = n; guard += 1; } - if !extend - && let Some(m) = sheet.merge_at(from) - { + if !extend && let Some(m) = sheet.merge_at(from) { // Leaving a merged cell moves from its edge. if dr > 0 { t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col); @@ -674,7 +761,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let src = clip.range; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. - let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() && cell_param(p, "at").is_none() && !sel.is_full_cols() && !sel.is_full_rows() && sel.count() <= 4_000_000; + let tile = sel.height() % h == 0 + && sel.width() % w == 0 + && !sel.is_single() + && cell_param(p, "at").is_none() + && !sel.is_full_cols() + && !sel.is_full_rows() + && sel.count() <= 4_000_000; let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; let same_doc = s.doc()?.uid == clip.doc_uid; let cut_move = clip.cut && same_doc && what == What::All; @@ -706,7 +799,11 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let sc = CellRef::new(src.start.row + r, src.start.col + c); let (dr, dc) = if transpose { (c, r) } else { (r, c) }; let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; + let src_cell = if cut_move { + moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) + } else { + src_sheet.cell(sc).cloned() + }; let src_val = src_sheet.value(sc); if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { continue; @@ -719,11 +816,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if cut_move { return Some(std::sync::Arc::new(Formula::from_expr(e))); } - let moved = sheetcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + let moved = + sheetcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); Some(std::sync::Arc::new(Formula::from_expr(moved))) }; if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { String::new() } else { format!("{}!", sheetcraft_formula::quote_sheet(&src_sheet_name)) }; + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", sheetcraft_formula::quote_sheet(&src_sheet_name)) + }; new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); new.value = Value::Empty; } else { @@ -760,7 +862,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { What::Comments | What::Validation | What::ColumnWidths => {} } // Operations combine numbers with what is already there. - if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() { + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + { let a = old.value.clone(); let b = new.value.clone(); if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) @@ -887,7 +992,8 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }, None => continue, }; - let cells: Vec<(CellRef, Cell)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = + cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -991,7 +1097,9 @@ fn flash_fill(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; if n == 0 { - return Err(EngineError::Other("We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into())); + return Err(EngineError::Other( + "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), + )); } Ok(json!({"filled": n})) } @@ -1080,7 +1188,8 @@ fn find(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); } if all { - let list: Vec = hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); + let list: Vec = + hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); return Ok(json!({"count": list.len(), "results": list})); } // Next hit after the active cell. @@ -1109,7 +1218,8 @@ fn replace(s: &mut Session, p: &Json) -> Result { let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] }; let mut n = 0; for si in sheets { - let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, String)> = + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); for (c, text) in cells { if !all && c != active { continue; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c3dbeec9c..37a58dd63 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -50,12 +50,16 @@ fn open(s: &mut Session, p: &Json) -> Result { let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); // Replace an untouched blank Book1 like Excel does. - if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) { + if s.documents().len() == 1 + && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) + { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); let d = s.doc()?; - Ok(json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings})) + Ok( + json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), + ) } fn save(s: &mut Session, p: &Json) -> Result { @@ -122,7 +126,14 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result { fn properties(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let pr = &mut cx.wb.props; - for (k, slot) in [("title", &mut pr.title), ("subject", &mut pr.subject), ("author", &mut pr.author), ("company", &mut pr.company), ("keywords", &mut pr.keywords), ("description", &mut pr.description)] { + for (k, slot) in [ + ("title", &mut pr.title), + ("subject", &mut pr.subject), + ("author", &mut pr.author), + ("company", &mut pr.company), + ("keywords", &mut pr.keywords), + ("description", &mut pr.description), + ] { if let Some(v) = str_param(p, k) { *slot = v.to_string(); } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c6cee6593..b9a6d58df 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -9,21 +9,83 @@ use super::*; pub fn specs() -> Vec { vec![ // Font - cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle(s, p, |st| st.font.bold, |st, v| st.font.bold = v)), - cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.italic, |st, v| st.font.italic = v)), - cmd!("home.underline", "Underline", ["Home", "Font"], Some("Cmd+U"), "{range?, on?, style?: single|double|singleAccounting|doubleAccounting}", has_doc, underline), - cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline(s, &with(p, "style", json!("double")))), - cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.strike, |st, v| st.font.strike = v)), - cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.vert == VertAlign::Superscript, |st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline })), - cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.vert == VertAlign::Subscript, |st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline })), + cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.bold, + |st, v| st.font.bold = v + )), + cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.italic, + |st, v| st.font.italic = v + )), + cmd!( + "home.underline", + "Underline", + ["Home", "Font"], + Some("Cmd+U"), + "{range?, on?, style?: single|double|singleAccounting|doubleAccounting}", + has_doc, + underline + ), + cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline( + s, + &with(p, "style", json!("double")) + )), + cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.strike, + |st, v| st.font.strike = v + )), + cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.vert == VertAlign::Superscript, + |st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline } + )), + cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.vert == VertAlign::Subscript, + |st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline } + )), cmd!("home.fontName", "Font", ["Home", "Font"], None, "{range?, name: \"Calibri\"}", has_doc, font_name), cmd!("home.fontSize", "Font Size", ["Home", "Font"], None, "{range?, size: 11}", has_doc, font_size), cmd!("home.increaseFontSize", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+>"), "{range?}", has_doc, |s, p| step_font(s, p, true)), - cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font(s, p, false)), - cmd!("home.fontColor", "Font Color", ["Home", "Font"], None, "{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}", has_doc, font_color), + cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font( + s, p, false + )), + cmd!( + "home.fontColor", + "Font Color", + ["Home", "Font"], + None, + "{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}", + has_doc, + font_color + ), cmd!("home.fillColor", "Fill Color", ["Home", "Font"], None, "{range?, color: \"#FFFF00\" | \"none\" | {theme, tint}}", has_doc, fill_color), - cmd!("home.borders", "Borders", ["Home", "Font"], None, "{range?, preset: bottom|top|left|right|none|all|outside|thickOutside|thickBottom|doubleBottom|topBottom|topThickBottom|topDoubleBottom|insideHorizontal|insideVertical|inside|diagonalDown|diagonalUp, style?: thin|medium|thick|dashed|dotted|double|hair…, color?}", has_doc, borders), - cmd!("home.formatCells", "Format Cells…", ["Home", "Cells", "Format"], Some("Cmd+1"), "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", has_doc, format_cells), + cmd!( + "home.borders", + "Borders", + ["Home", "Font"], + None, + "{range?, preset: bottom|top|left|right|none|all|outside|thickOutside|thickBottom|doubleBottom|topBottom|topThickBottom|topDoubleBottom|insideHorizontal|insideVertical|inside|diagonalDown|diagonalUp, style?: thin|medium|thick|dashed|dotted|double|hair…, color?}", + has_doc, + borders + ), + cmd!( + "home.formatCells", + "Format Cells…", + ["Home", "Cells", "Format"], + Some("Cmd+1"), + "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", + has_doc, + format_cells + ), // Alignment cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)), cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)), @@ -32,35 +94,108 @@ pub fn specs() -> Vec { cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)), cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)), cmd!("home.alignBottom", "Bottom Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Bottom)), - cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.align.wrap, |st, v| st.align.wrap = v)), - cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.align.shrink, |st, v| st.align.shrink = v)), + cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.align.wrap, + |st, v| st.align.wrap = v + )), + cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.align.shrink, + |st, v| st.align.shrink = v + )), cmd!("home.increaseIndent", "Increase Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Tab"), "{range?}", has_doc, |s, p| indent(s, p, 1)), - cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent(s, p, -1)), - cmd!("home.orientation", "Orientation", ["Home", "Alignment"], None, "{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}", has_doc, orientation), + cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent( + s, p, -1 + )), + cmd!( + "home.orientation", + "Orientation", + ["Home", "Alignment"], + None, + "{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}", + has_doc, + orientation + ), cmd!("home.mergeCenter", "Merge & Center", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "center")), cmd!("home.mergeAcross", "Merge Across", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "across")), cmd!("home.mergeCells", "Merge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "cells")), cmd!("home.unmergeCells", "Unmerge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "unmerge")), // Number - cmd!("home.numberFormat", "Number Format", ["Home", "Number"], None, "{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}", has_doc, number_format), - cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(s, p, ACCOUNTING)), + cmd!( + "home.numberFormat", + "Number Format", + ["Home", "Number"], + None, + "{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}", + has_doc, + number_format + ), + cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt( + s, p, ACCOUNTING + )), cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")), cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)), cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)), cmd!("home.decreaseDecimal", "Decrease Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, -1)), // Styles - cmd!("home.cellStyle", "Cell Styles", ["Home", "Styles"], None, "{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}", has_doc, cell_style), + cmd!( + "home.cellStyle", + "Cell Styles", + ["Home", "Styles"], + None, + "{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}", + has_doc, + cell_style + ), // Cells › Format - cmd!("home.rowHeight", "Row Height…", ["Home", "Cells", "Format"], None, "{rows?: \"2:5\", height: 20 (points on screen)}", has_doc, row_height), - cmd!("home.columnWidth", "Column Width…", ["Home", "Cells", "Format"], None, "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", has_doc, col_width), + cmd!( + "home.rowHeight", + "Row Height…", + ["Home", "Cells", "Format"], + None, + "{rows?: \"2:5\", height: 20 (points on screen)}", + has_doc, + row_height + ), + cmd!( + "home.columnWidth", + "Column Width…", + ["Home", "Cells", "Format"], + None, + "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", + has_doc, + col_width + ), cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows), cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols), cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width), - cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(s, p, true, true)), - cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(s, p, false, true)), - cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(s, p, true, false)), - cmd!("home.unhideColumns", "Unhide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+0"), "{cols?}", has_doc, |s, p| hide(s, p, false, false)), - cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.protection.locked, |st, v| st.protection.locked = v)), + cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide( + s, p, true, true + )), + cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide( + s, p, false, true + )), + cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide( + s, p, true, false + )), + cmd!( + "home.unhideColumns", + "Unhide Columns", + ["Home", "Cells", "Format", "Hide & Unhide"], + Some("Cmd+Shift+0"), + "{cols?}", + has_doc, + |s, p| hide(s, p, false, false) + ), + cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.protection.locked, + |st, v| st.protection.locked = v + )), ] } @@ -100,7 +235,8 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> map.entry(line).or_default().style = Some(new); } // Existing cells in those lines. - let cells: Vec<(CellRef, StyleId)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, StyleId)> = + cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default(); for (c, st) in cells { let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f)); cx.sheet_mut(sheet)?.set_style(c, new); @@ -110,6 +246,9 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> if r.count() > 4_000_000 { return Err(bad("format", "selection too large to format cell by cell")); } + if r.height() <= 100_000 { + cx.fit_rows.extend((r.start.row..=r.end.row).map(|row| (sheet, row))); + } for c in r.iter() { let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f)); @@ -171,14 +310,17 @@ const SIZES: [f32; 16] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24 fn step_font(s: &mut Session, p: &Json, up: bool) -> Result { let cur = active_style(s)?.font.size; - let next = if up { SIZES.iter().copied().find(|x| *x > cur).unwrap_or(409.0) } else { SIZES.iter().rev().copied().find(|x| *x < cur).unwrap_or(1.0) }; + let next = + if up { SIZES.iter().copied().find(|x| *x > cur).unwrap_or(409.0) } else { SIZES.iter().rev().copied().find(|x| *x < cur).unwrap_or(1.0) }; apply_style(s, p, |st| st.font.size = next)?; Ok(json!({"size": next})) } pub(crate) fn color_param(v: Option<&Json>) -> Option { match v? { - Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => Some(Color::Auto), + Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => { + Some(Color::Auto) + } Json::String(s) => Color::from_hex(s), Json::Object(o) => { let theme = o.get("theme")?.as_u64()? as u8; @@ -390,7 +532,8 @@ fn orientation(s: &mut Session, p: &Json) -> Result { fn merge(s: &mut Session, p: &Json, how: &str) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; - let how = if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how }; + let how = + if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how }; edit(s, |cx| { let mut touched = Vec::new(); for r in &ranges { @@ -454,7 +597,10 @@ pub fn format_code_for(name: &str) -> &str { } fn number_format(s: &mut Session, p: &Json) -> Result { - let code = str_param(p, "code").map(str::to_string).or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string())).ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?; + let code = str_param(p, "code") + .map(str::to_string) + .or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string())) + .ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?; set_fmt(s, p, &code) } @@ -516,12 +662,13 @@ pub fn adjust_decimals(code: &str, value: &sheetcraft_core::Value, delta: i32) - } } } else if let Some(pt) = point - && ld > pt { - out.remove(ld); - if ld == pt + 1 { - out.remove(pt); - } + && ld > pt + { + out.remove(ld); + if ld == pt + 1 { + out.remove(pt); } + } out.into_iter().collect() }) .collect(); @@ -659,17 +806,64 @@ pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option", esc(&sh.name)); + let _ = write!( + out, + "{}
", + esc(&sh.name) + ); if let Some(r) = sh.used_range() { for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { if sh.is_row_hidden(row) { @@ -133,7 +137,12 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { _ => "left", }; let _ = write!(css, "text-align:{align};"); - let span = sh.merges.iter().find(|m| m.start == c).map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())).unwrap_or_default(); + let span = sh + .merges + .iter() + .find(|m| m.start == c) + .map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())) + .unwrap_or_default(); let _ = write!(out, "", esc(&crate::display::cell_text(wb, sh, c))); } out.push_str(""); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 62a530da5..6faace9ec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -256,7 +256,8 @@ impl Session { let result = match result { Ok(r) => r, Err(p) => { - let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + let msg = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); // Restore the workbook as it was. if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 6dcb8fb09..a322d2196 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -80,25 +80,35 @@ fn sales(s: &mut Session) -> bool { ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": col, "function": "sum"})); } ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Growth", "function": "average"})); + ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Trend", "function": "none"})); ok &= run(s, "home.numberFormat", json!({"range": "C5:G11", "code": "\"$\"#,##0"})); ok &= run(s, "home.numberFormat", json!({"range": "H5:H11", "code": "0.0%"})); ok &= run(s, "home.conditionalFormat", json!({"range": "G5:G10", "rule": {"type": "dataBar", "color": "#5B9BD5"}})); ok &= run(s, "home.conditionalFormat", json!({"range": "H5:H10", "rule": {"type": "iconSet", "set": "3Arrows"}})); ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 20})); - ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 90})); + ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 132})); ok &= run(s, "home.columnWidth", json!({"cols": "C:G", "width": 84})); ok &= run(s, "home.columnWidth", json!({"cols": "H:I", "width": 76})); ok &= run(s, "selection.set", json!({"range": "B4:F10"})); ok &= run(s, "insert.chart", json!({"type": "column", "title": "Revenue by Region", "at": "K3", "width": 460, "height": 270})); ok &= run(s, "selection.set", json!({"range": "B4:B10"})); // Pie of totals. - ok &= run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260})); + ok &= + run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260})); if let Ok(d) = s.doc_mut() { let wb = Arc::make_mut(&mut d.wb); if let Some(sh) = wb.sheet_mut(0) && let Some(c) = sh.charts.last_mut() { - c.series = vec![sheetcraft_model::Series { name: Some("Sales!$G$4".into()), categories: Some("Sales!$B$5:$B$10".into()), values: "Sales!$G$5:$G$10".into(), bubble_sizes: None, color: None, secondary: false, kind: None }]; + c.series = vec![sheetcraft_model::Series { + name: Some("Sales!$G$4".into()), + categories: Some("Sales!$B$5:$B$10".into()), + values: "Sales!$G$5:$G$10".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }]; } } ok &= run(s, "range.setValues", json!({"range": "B14", "values": [["Best region"], ["Average quarter"], ["Online share"], ["Year-over-year"]]})); @@ -146,7 +156,11 @@ fn budget(s: &mut Session) -> bool { ok &= run(s, "home.cellStyle", json!({"range": "B15:F15", "name": "Total"})); ok &= run(s, "home.numberFormat", json!({"range": "C5:E15", "format": "Accounting"})); ok &= run(s, "home.numberFormat", json!({"range": "F5:F15", "code": "0%"})); - ok &= run(s, "home.conditionalFormat", json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}})); + ok &= run( + s, + "home.conditionalFormat", + json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}}), + ); ok &= run(s, "home.conditionalFormat", json!({"range": "F5:F14", "rule": {"type": "colorScale", "colors": ["#63BE7B", "#FFEB84", "#F8696B"]}})); ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 130})); ok &= run(s, "home.columnWidth", json!({"cols": "C:E", "width": 90})); @@ -172,12 +186,20 @@ fn grades(s: &mut Session) -> bool { let mut ok = run(s, "range.setValues", json!({"range": "A1", "values": rows})); for r in 2..=9 { ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=AVERAGE(B{r}:E{r})")})); - ok &= run(s, "cell.set", json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")})); + ok &= run( + s, + "cell.set", + json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")}), + ); } ok &= run(s, "home.numberFormat", json!({"range": "F2:F9", "code": "0.0"})); ok &= run(s, "insert.table", json!({"range": "A1:G9", "style": "TableStyleLight9", "name": "Grades"})); ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 120})); - ok &= run(s, "home.conditionalFormat", json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}})); + ok &= run( + s, + "home.conditionalFormat", + json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}}), + ); ok &= run(s, "home.conditionalFormat", json!({"range": "F2:F9", "rule": {"type": "dataBar", "color": "#63BE7B"}})); ok } diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 24915217c..4fccfb4ae 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -141,12 +141,29 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef { return cur; } let beyond = match used { - Some(u) => (dr > 0 && cur.row > u.end.row) || (dc > 0 && cur.col > u.end.col) || (dr < 0 && cur.row < u.start.row) || (dc < 0 && cur.col < u.start.col), + Some(u) => { + (dr > 0 && cur.row > u.end.row) + || (dc > 0 && cur.col > u.end.col) + || (dr < 0 && cur.row < u.start.row) + || (dc < 0 && cur.col < u.start.col) + } None => true, }; if beyond { - let r = if dr > 0 { MAX_ROWS - 1 } else if dr < 0 { 0 } else { cur.row }; - let c = if dc > 0 { MAX_COLS - 1 } else if dc < 0 { 0 } else { cur.col }; + let r = if dr > 0 { + MAX_ROWS - 1 + } else if dr < 0 { + 0 + } else { + cur.row + }; + let c = if dc > 0 { + MAX_COLS - 1 + } else if dc < 0 { + 0 + } else { + cur.col + }; return CellRef::new(r, c); } match cur.offset(dr, dc) { diff --git a/crates/engine/src/tables.rs b/crates/engine/src/tables.rs index f3bacfc00..b239e7071 100644 --- a/crates/engine/src/tables.rs +++ b/crates/engine/src/tables.rs @@ -119,9 +119,10 @@ pub fn table_cell_style(wb: &Workbook, sheet: usize, t: &Table, c: CellRef) -> S } if let Some(l) = lk.line && t.style.contains("Light") - && c.row == t.range.end.row { - s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; - } + && c.row == t.range.end.row + { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; + } } if (t.first_col && c.col == t.range.start.col) || (t.last_col && c.col == t.range.end.col) { s.font.bold = true; diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 7cef10685..d38aa9fa2 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -113,8 +113,20 @@ fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Op if lo >= at && hi < end { return None; // fully deleted } - let lo2 = if lo < at { lo } else if lo >= end { lo - count } else { at }; - let hi2 = if hi < at { hi } else if hi >= end { hi - count } else { at.saturating_sub(1) }; + let lo2 = if lo < at { + lo + } else if lo >= end { + lo - count + } else { + at + }; + let hi2 = if hi < at { + hi + } else if hi >= end { + hi - count + } else { + at.saturating_sub(1) + }; Some((lo2, hi2.max(lo2))) } } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 49e58000c..46264f4e2 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -77,7 +77,8 @@ fn errors() { #[test] fn no_panic_on_garbage() { - let samples = ["(((((", "[[[", "'''", "!!!", "$$$", "=@@@", "A1:B2:C3:D4", "{,}", "{;}", "F(,,,,)", ":::", "1e", "1e+", ".", "..", "$A$", "XFD1048577"]; + let samples = + ["(((((", "[[[", "'''", "!!!", "$$$", "=@@@", "A1:B2:C3:D4", "{,}", "{;}", "F(,,,,)", ":::", "1e", "1e+", ".", "..", "$A$", "XFD1048577"]; for s in samples { let _ = parse(s); } diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 000000000..90d835967 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "sheetcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft MCP server: JSON-RPC 2.0 over stdio, driving a running app or a headless session" + +[lints] +workspace = true + +[dependencies] +sheetcraft-engine = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 000000000..cd31151cb --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,113 @@ +//! Where MCP tool calls end up: a control-channel method call. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Something that answers control-channel methods (`engine.execute`, `engine.commands`, +/// `document.inspect`, `sheet.read`, `ui.screenshot`, …). +pub trait Backend { + /// Call one method. `Ok` carries the `result`, `Err` the error message. + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a real UI is attached (`ui.screenshot` and dialogs work). + fn has_ui(&self) -> bool; + /// Short human description ("headless", "connected to 127.0.0.1:7979"). + fn describe(&self) -> String; +} + +/// Read/write timeout on the control connection. +const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// Longest reply line accepted from the app (guards against a runaway peer). +const MAX_REPLY: u64 = 256 * 1024 * 1024; + +/// A running SheetCraft app, reached through its loopback control port. +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Remote { + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + pub fn connect(addr: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + r.reconnect()?; + Ok(r) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn reconnect(&mut self) -> std::io::Result<()> { + self.conn = None; + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); + for sa in self.addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) { + Ok(s) => { + s.set_nodelay(true).ok(); + s.set_read_timeout(Some(IO_TIMEOUT)).ok(); + s.set_write_timeout(Some(IO_TIMEOUT)).ok(); + let read = s.try_clone()?; + self.conn = Some((BufReader::new(read), s)); + return Ok(()); + } + Err(e) => last = e, + } + } + Err(last) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { + if self.conn.is_none() { + self.reconnect()?; + } + let Some((reader, writer)) = self.conn.as_mut() else { + return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + let n = std::io::Read::take(reader, MAX_REPLY).read_line(&mut reply)?; + if n == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + } + Ok(reply) + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + let line = json!({"id": id, "method": method, "params": params}).to_string(); + // One retry with a fresh connection (the app may have restarted). + let reply = match self.roundtrip(&line) { + Ok(r) => r, + Err(_) => { + self.conn = None; + self.roundtrip(&line).map_err(|e| { + self.conn = None; + format!("SheetCraft app at {} is not reachable: {e}", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string()) + } + } + + fn has_ui(&self) -> bool { + true + } + + fn describe(&self) -> String { + format!("connected to the SheetCraft app at {}", self.addr) + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 000000000..57fc5be0d --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,94 @@ +//! An in-process engine session that answers the engine-level control-channel methods itself. + +use serde_json::{Value, json}; +use sheetcraft_engine::Session; + +use crate::backend::Backend; + +/// Error for methods that need a window. +pub(crate) const NO_UI: &str = "no UI in headless mode: start the app with `sheetcraft --control 7979` and run the MCP server \ +with `sheetcraft-cli mcp --connect 7979`"; + +/// Headless backend: a [`Session`] with one blank workbook to start with. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +impl Headless { + /// A session with a blank workbook (`Book1`) open. + pub fn new() -> Self { + let mut session = Session::new(); + session.new_workbook(); + Self { session } + } + + /// A session wrapping an existing one (e.g. with a file already open). + pub fn with_session(session: Session) -> Self { + Self { session } + } + + fn execute(&mut self, id: &str, params: Value) -> Result { + let r = self.session.execute(id, params).map_err(|e| e.to_string()); + // Programmatic calls never open dialogs; drop anything a command queued for a UI. + for req in self.session.take_ui_requests() { + log::debug!("headless: ignoring UI request {req:?}"); + } + r + } + + fn commands(&self, params: &Value) -> Value { + let q = params.get("search").and_then(Value::as_str).map(str::to_ascii_lowercase); + let list: Vec = self + .session + .commands() + .into_iter() + .filter(|c| { + q.as_ref().is_none_or(|q| { + c.id.to_ascii_lowercase().contains(q.as_str()) + || c.label.to_ascii_lowercase().contains(q.as_str()) + || c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str())) + }) + }) + .map(|c| serde_json::to_value(c).unwrap_or_default()) + .collect(); + Value::Array(list) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let params = if params.is_null() { json!({}) } else { params }; + match method { + "ping" => Ok(json!("pong")), + "engine.execute" => { + let id = params.get("command").and_then(Value::as_str).ok_or("engine.execute: missing `command`")?.to_string(); + let p = params.get("params").cloned().unwrap_or_else(|| json!({})); + if !(p.is_object() || p.is_null()) { + return Err(format!("engine.execute: `params` of `{id}` must be an object")); + } + self.execute(&id, p) + } + "engine.commands" => Ok(self.commands(¶ms)), + "engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())), + m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")), + // Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are + // commands with the same id. + m if sheetcraft_engine::find_command(m).is_some() => self.execute(m, params), + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine, no window)".to_string() + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 000000000..74125c13c --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,36 @@ +//! SheetCraft's MCP server. +//! +//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes SheetCraft as MCP tools and +//! resources and forwards everything to a [`Backend`]: +//! +//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`sheetcraft --control 7979`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). +//! - [`Headless`] hosts an in-process [`sheetcraft_engine::Session`] and implements the +//! engine-level control-channel methods itself, so agents can build workbooks without a window. +//! +//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod backend; +mod headless; +mod server; +mod tools; + +pub use backend::{Backend, Remote}; +pub use headless::Headless; +pub use server::{COMMANDS_URI, FUNCTIONS_URI, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, WORKBOOK_URI}; +pub use tools::{ToolResult, call_tool, tool_definitions}; + +/// Default control-channel port of the desktop app. +pub const DEFAULT_PORT: u16 = 7979; + +/// `"7979"` → `"127.0.0.1:7979"`; `"host:port"` is kept as is. +pub fn control_addr(s: &str) -> String { + if s.parse::().is_ok() { format!("127.0.0.1:{s}") } else { s.to_string() } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 000000000..e26787bed --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,197 @@ +//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::backend::Backend; +use crate::tools::{call_tool, check_arguments, tool_definitions}; + +/// The MCP revision we implement. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +/// Revisions we can speak; an older client gets its own version echoed back. +pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; +const RESOURCE_NOT_FOUND: i64 = -32002; + +/// Longest accepted message line (16 MB): bigger payloads belong in files. +const MAX_LINE: usize = 16 * 1024 * 1024; + +const INSTRUCTIONS: &str = "SheetCraft is a spreadsheet app (an Excel clone). Cells use A1 notation (\"B2\", \"A1:D10\", \ +\"Sheet2!A1:B3\"). Every action is a command: find ids and parameters with list_commands and run any of them with \ +execute_command. Convenience tools cover the common flow: write_range / set_cell to enter values and formulas (strings \ +starting with = are formulas), read_range / get_cell to read computed values back, format_range, create_table, \ +insert_chart, sort_range, filter, evaluate_formula (try a formula without changing the sheet), undo / redo, \ +open_workbook / save_workbook. inspect_workbook summarizes sheets, used ranges, tables, charts and names. \ +list_functions lists the worksheet functions."; + +/// Resource URIs. +pub const WORKBOOK_URI: &str = "sheetcraft://workbook"; +pub const COMMANDS_URI: &str = "sheetcraft://commands"; +pub const FUNCTIONS_URI: &str = "sheetcraft://functions"; + +/// An MCP server bound to one backend. +pub struct Server { + backend: Box, + initialized: bool, +} + +fn response(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error(id: Value, code: i64, message: impl Into) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +impl Server { + pub fn new(backend: Box) -> Self { + Self { backend, initialized: false } + } + + pub fn backend(&mut self) -> &mut dyn Backend { + self.backend.as_mut() + } + + /// Whether the client has sent `notifications/initialized`. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is + /// the protocol stream). + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + buf.clear(); + let n = input.read_until(b'\n', &mut buf)?; + if n == 0 { + return Ok(()); + } + let reply = match std::str::from_utf8(&buf) { + Ok(line) => self.handle_line(line), + Err(_) => Some(error(Value::Null, PARSE_ERROR, "message is not valid UTF-8").to_string()), + }; + if let Some(reply) = reply { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + } + + /// Handle one line; returns the reply line (None for notifications and blank lines). + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + if line.len() > MAX_LINE { + return Some(error(Value::Null, INVALID_REQUEST, "message too large (16 MB max)").to_string()); + } + let reply = match serde_json::from_str::(line) { + Ok(Value::Array(batch)) => { + // Batches were removed in 2025-06-18; still answer older clients sensibly. + if batch.is_empty() { + Some(error(Value::Null, INVALID_REQUEST, "empty batch")) + } else { + let replies: Vec = batch.into_iter().filter_map(|m| self.handle(m)).collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) + } + } + Ok(msg) => self.handle(msg), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one JSON-RPC message; `None` for notifications and responses. + pub fn handle(&mut self, msg: Value) -> Option { + let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) }; + let id = o.get("id").cloned(); + let Some(method) = o.get("method").and_then(Value::as_str) else { + // A response to a server→client request (we send none): ignore. Anything else is invalid. + if o.contains_key("result") || o.contains_key("error") { + return None; + } + return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`")); + }; + let params = o.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + self.notification(method); + return None; + }; + if !(id.is_string() || id.is_number()) { + return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number")); + } + if !(params.is_object() || params.is_null()) { + return Some(error(id, INVALID_PARAMS, "`params` must be an object")); + } + Some(match self.request(method, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn notification(&mut self, method: &str) { + match method { + "notifications/initialized" => self.initialized = true, + "notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {} + other => log::debug!("ignoring notification {other}"), + } + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); + let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "prompts": {"listChanged": false}}, + "serverInfo": {"name": "sheetcraft", "title": "SheetCraft", "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + let args = match params.get("arguments") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err((INVALID_PARAMS, "tool `arguments` must be an object".to_string())), + }; + check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?; + Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + } + "resources/list" => Ok(json!({"resources": [ + {"uri": WORKBOOK_URI, "name": "workbook", "title": "Active workbook", "description": "Sheets, used ranges, tables, charts, names, selection and undo history of the active workbook (document.inspect)", "mimeType": "application/json"}, + {"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, ribbon path, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"}, + {"uri": FUNCTIONS_URI, "name": "functions", "title": "Worksheet functions", "description": "Every worksheet function: name, category, signature, description (formulas.functions)", "mimeType": "application/json"}, + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; + let v = match uri { + WORKBOOK_URI => self.backend.call("document.inspect", json!({})), + COMMANDS_URI => self.backend.call("engine.commands", json!({})), + FUNCTIONS_URI => self.backend.call("engine.execute", json!({"command": "formulas.functions", "params": {}})), + _ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))), + } + .map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + } + "prompts/list" => Ok(json!({"prompts": []})), + "prompts/get" => Err((INVALID_PARAMS, "no prompts are defined".to_string())), + "logging/setLevel" => Ok(json!({})), + "completion/complete" => Ok(json!({"completion": {"values": [], "hasMore": false}})), + other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 000000000..28d8c9a9a --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,383 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, control_addr, tool_definitions}; + +fn server() -> Server { + Server::new(Box::new(Headless::new())) +} + +fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply = s.handle_line(&line).expect("reply"); + let v: Value = serde_json::from_str(&reply).expect("reply is JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], id); + v +} + +fn call(s: &mut Server, name: &str, args: Value) -> Value { + let v = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args})); + assert!(v.get("error").is_none(), "{v}"); + v["result"].clone() +} + +fn text_of(result: &Value) -> String { + result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect() +} + +/// Call a tool that must succeed; returns its JSON payload. +fn ok(s: &mut Server, name: &str, args: Value) -> Value { + let r = call(s, name, args); + assert_eq!(r["isError"], false, "{name}: {r}"); + serde_json::from_str(&text_of(&r)).unwrap_or(Value::Null) +} + +/// Call a tool that must fail; returns its message. +fn fails(s: &mut Server, name: &str, args: Value) -> String { + let r = call(s, name, args); + assert_eq!(r["isError"], true, "{name} should fail: {r}"); + text_of(&r) +} + +fn err_code(v: &Value) -> i64 { + v["error"]["code"].as_i64().unwrap_or_else(|| panic!("expected an error: {v}")) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sheetcraft-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +// ---------- protocol ---------- + +#[test] +fn initialize_and_version_negotiation() { + let mut s = server(); + let v = + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})); + let r = &v["result"]; + assert_eq!(r["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(r["serverInfo"]["name"], "sheetcraft"); + assert!(r["capabilities"]["tools"].is_object()); + assert!(r["capabilities"]["resources"].is_object()); + assert!(r["instructions"].as_str().unwrap().contains("headless")); + // Older supported version is echoed back; unknown versions get ours. + let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "2024-11-05"})); + assert_eq!(v["result"]["protocolVersion"], "2024-11-05"); + let v = rpc(&mut s, 3, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION); + assert!(!s.is_initialized()); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None); + assert!(s.is_initialized()); + assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({})); + assert_eq!(rpc(&mut s, 5, "prompts/list", json!({}))["result"]["prompts"], json!([])); +} + +#[test] +fn tools_list_has_schemas() { + let mut s = server(); + let v = rpc(&mut s, 1, "tools/list", json!({})); + let tools = v["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for want in [ + "execute_command", + "list_commands", + "inspect_workbook", + "read_range", + "write_range", + "set_cell", + "get_cell", + "evaluate_formula", + "select", + "format_range", + "insert_chart", + "create_table", + "sort_range", + "filter", + "open_workbook", + "save_workbook", + "new_workbook", + "undo", + "redo", + "screenshot", + "list_functions", + ] { + assert!(names.contains(&want), "missing tool {want}"); + } + for t in tools { + assert_eq!(t["inputSchema"]["type"], "object", "{t}"); + assert!(t["description"].as_str().unwrap().len() > 20, "{t}"); + let name = t["name"].as_str().unwrap(); + assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name} is not snake_case"); + for r in t["inputSchema"]["required"].as_array().into_iter().flatten() { + assert!(t["inputSchema"]["properties"].get(r.as_str().unwrap()).is_some(), "{name}: required {r} not in properties"); + } + } + assert_eq!(tools.len(), tool_definitions().len()); +} + +#[test] +fn malformed_input_never_panics() { + let mut s = server(); + assert_eq!(s.handle_line(" "), None); + let parse = |s: &mut Server, l: &str| -> Value { serde_json::from_str(&s.handle_line(l).unwrap()).unwrap() }; + assert_eq!(err_code(&parse(&mut s, "{not json")), -32700); + assert_eq!(err_code(&parse(&mut s, "\"just a string\"")), -32600); + assert_eq!(err_code(&parse(&mut s, "[]")), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":1}"#)), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":{"x":1},"method":"ping"}"#)), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":[1,2]}"#)), -32602); + // Unknown method. + assert_eq!(err_code(&rpc(&mut s, 1, "nope/nothing", json!({}))), -32601); + // Bad tools/call params. + assert_eq!(err_code(&rpc(&mut s, 2, "tools/call", json!({}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 3, "tools/call", json!({"name": "no_such_tool"}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 4, "tools/call", json!({"name": "set_cell", "arguments": "x"}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 5, "tools/call", json!({"name": "set_cell", "arguments": {"cell": "A1"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 6, "tools/call", json!({"name": "set_cell", "arguments": {"cell": 5, "input": "x"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 7, "tools/call", json!({"name": "insert_chart", "arguments": {"range": "A1", "type": "piechart"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 8, "tools/call", json!({"name": "get_cell", "arguments": {"cell": "A1", "bogus": 1}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 9, "resources/read", json!({}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 10, "resources/read", json!({"uri": "sheetcraft://nope"}))), -32002); + // Responses and unknown notifications are ignored. + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#), None); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/whatever"}"#), None); + // A batch still gets answers. + let v = parse(&mut s, r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"}]"#); + assert_eq!(v.as_array().unwrap().len(), 1); + // Tool-level errors are isError results, not protocol errors. + assert!(fails(&mut s, "set_cell", json!({"cell": "not a cell!!", "input": "1"})).contains("set_cell")); + assert!(fails(&mut s, "execute_command", json!({"command": "no.such.command"})).contains("unknown command")); + fails(&mut s, "read_range", json!({"range": "ZZZZZZZZ99999999999"})); + fails(&mut s, "format_range", json!({"range": "A1"})); + fails(&mut s, "sort_range", json!({"keys": []})); + fails(&mut s, "open_workbook", json!({"path": "/definitely/not/here.xlsx"})); + fails(&mut s, "save_workbook", json!({})); + assert!(fails(&mut s, "screenshot", json!({"path": "/tmp/x.png"})).contains("no UI in headless mode")); +} + +#[test] +fn every_tool_survives_hostile_arguments() { + let hostile = [json!({}), json!({"range": "A1:XFD1048576"}), json!({"range": "", "cell": "", "path": "", "formula": "", "command": ""})]; + for t in tool_definitions() { + let name = t["name"].as_str().unwrap(); + for args in &hostile { + let mut s = server(); + // Either a protocol error or a tool result; never a panic. + let _ = + s.handle_line(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string()); + } + } +} + +#[test] +fn resources() { + let mut s = server(); + let v = rpc(&mut s, 1, "resources/list", json!({})); + let uris: Vec<&str> = v["result"]["resources"].as_array().unwrap().iter().map(|r| r["uri"].as_str().unwrap()).collect(); + assert_eq!(uris, ["sheetcraft://workbook", "sheetcraft://commands", "sheetcraft://functions"]); + for uri in uris { + let v = rpc(&mut s, 2, "resources/read", json!({"uri": uri})); + let text = v["result"]["contents"][0]["text"].as_str().unwrap(); + let parsed: Value = serde_json::from_str(text).unwrap(); + assert!(!parsed.is_null(), "{uri}"); + } + let v = rpc(&mut s, 3, "resources/read", json!({"uri": "sheetcraft://functions"})); + assert!(v["result"]["contents"][0]["text"].as_str().unwrap().contains("VLOOKUP")); +} + +#[test] +fn basic_tools() { + let mut s = server(); + let cmds = ok(&mut s, "list_commands", json!({"search": "bold"})); + assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "home.bold")); + let all = ok(&mut s, "list_commands", json!({})); + assert!(all.as_array().unwrap().len() > 100); + let f = ok(&mut s, "list_functions", json!({"search": "vlookup"})); + assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP")); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0)); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"})); + ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0)); + ok(&mut s, "undo", json!({})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], Value::Null); + ok(&mut s, "redo", json!({})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); + ok(&mut s, "select", json!({"range": "A1:A2"})); + assert_eq!(ok(&mut s, "inspect_workbook", json!({}))["selection"], "A1:A2"); + ok(&mut s, "new_workbook", json!({"sample": "sales"})); + let info = ok(&mut s, "inspect_workbook", json!({})); + assert!(info["sheets"][0]["cells"].as_u64().unwrap() > 10, "{info}"); +} + +#[test] +fn sort_and_filter() { + let mut s = server(); + ok(&mut s, "write_range", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})); + ok(&mut s, "sort_range", json!({"range": "A1:B4", "keys": [{"column": "B", "order": "desc"}], "header": true})); + let v = ok(&mut s, "read_range", json!({"range": "A2:A4"})); + assert_eq!(v["values"], json!([["c"], ["b"], ["a"]])); + ok(&mut s, "sort_range", json!({"range": "A1:B4", "keys": ["A"], "header": true})); + let v = ok(&mut s, "read_range", json!({"range": "A2:A4"})); + assert_eq!(v["values"], json!([["a"], ["b"], ["c"]])); + ok(&mut s, "filter", json!({"range": "A1:B4", "column": "A", "values": ["a", "c"]})); + let info = ok(&mut s, "inspect_workbook", json!({})); + assert_eq!(info["sheets"][0]["autofilter"], "A1:B4"); +} + +#[test] +fn save_and_open_roundtrip() { + let mut s = server(); + ok(&mut s, "write_range", json!({"range": "B2", "values": [[1, 2], [3, "=SUM(B2:C2)+B3"]]})); + let path = tmp("roundtrip.xlsx"); + let p = path.to_string_lossy().to_string(); + ok(&mut s, "save_workbook", json!({"path": p})); + ok(&mut s, "save_workbook", json!({})); + ok(&mut s, "new_workbook", json!({})); + ok(&mut s, "open_workbook", json!({"path": p})); + let v = ok(&mut s, "read_range", json!({"range": "B2:C3", "formulas": true})); + assert_eq!(v["values"], json!([[1.0, 2.0], [3.0, "=SUM(B2:C2)+B3"]])); + let v = ok(&mut s, "read_range", json!({"range": "C3"})); + assert_eq!(v["values"], json!([[6.0]])); + let _ = std::fs::remove_file(path); +} + +/// An agent builds a small monthly budget through MCP tool calls only. +#[test] +fn agent_builds_a_budget() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "agent", "version": "1"}})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + + ok( + &mut s, + "write_range", + json!({"range": "A1", "values": [ + ["Category", "January", "February", "March"], + ["Rent", 1200, 1200, 1250], + ["Groceries", 410.5, 389.25, 402], + ["Utilities", 150, 162, 140], + ["Transport", 90, 85, 110], + ]}), + ); + ok(&mut s, "set_cell", json!({"cell": "A6", "input": "Total"})); + for col in ["B", "C", "D"] { + ok(&mut s, "set_cell", json!({"cell": format!("{col}6"), "input": format!("=SUM({col}2:{col}5)")})); + } + ok(&mut s, "set_cell", json!({"cell": "E1", "input": "Quarter"})); + ok( + &mut s, + "write_range", + json!({"range": "E2", "values": [["=SUM(B2:D2)"], ["=SUM(B3:D3)"], ["=SUM(B4:D4)"], ["=SUM(B5:D5)"], ["=SUM(B6:D6)"]]}), + ); + + let fmt = + ok(&mut s, "format_range", json!({"range": "A1:E1", "bold": true, "fillColor": "#DDEBF7", "horizontalAlign": "center", "borders": "bottom"})); + assert_eq!(fmt["applied"].as_array().unwrap().len(), 4, "{fmt}"); + ok(&mut s, "format_range", json!({"range": "B2:E6", "numberFormat": "#,##0.00"})); + ok(&mut s, "format_range", json!({"range": "A6:E6", "bold": true, "borders": {"preset": "top", "style": "double"}})); + + ok(&mut s, "create_table", json!({"range": "A1:E5", "style": "TableStyleLight9"})); + ok(&mut s, "insert_chart", json!({"range": "A1:D5", "type": "column", "title": "Monthly spend", "at": "G2"})); + + // Read back computed values. + let totals = ok(&mut s, "read_range", json!({"range": "B6:E6"})); + let row = totals["values"][0].as_array().unwrap(); + let nums: Vec = row.iter().map(|v| v.as_f64().unwrap()).collect(); + assert!((nums[0] - 1850.5).abs() < 1e-9, "{nums:?}"); + assert!((nums[1] - 1836.25).abs() < 1e-9, "{nums:?}"); + assert!((nums[2] - 1902.0).abs() < 1e-9, "{nums:?}"); + assert!((nums[3] - 5588.75).abs() < 1e-9, "{nums:?}"); + let shown = ok(&mut s, "read_range", json!({"range": "E6", "formatted": true})); + assert_eq!(shown["values"][0][0], "5,588.75"); + let formulas = ok(&mut s, "read_range", json!({"range": "B6", "formulas": true})); + assert_eq!(formulas["values"][0][0], "=SUM(B2:B5)"); + let cell = ok(&mut s, "get_cell", json!({"cell": "A1"})); + assert_eq!(cell["style"]["font"]["bold"], true, "{cell}"); + + let info = ok(&mut s, "inspect_workbook", json!({})); + let sheet = &info["sheets"][0]; + assert_eq!(sheet["tables"].as_array().unwrap().len(), 1, "{info}"); + assert_eq!(sheet["tables"][0]["range"], "A1:E5"); + assert_eq!(sheet["charts"].as_array().unwrap().len(), 1, "{info}"); + assert_eq!(sheet["charts"][0]["title"], "Monthly spend"); + + // Change an input: totals follow. + ok(&mut s, "set_cell", json!({"cell": "B2", "input": "1300"})); + let v = ok(&mut s, "read_range", json!({"range": "E6"})); + assert!((v["values"][0][0].as_f64().unwrap() - 5688.75).abs() < 1e-9); + // And the whole thing evaluates without touching the sheet. + let v = ok(&mut s, "evaluate_formula", json!({"formula": "=AVERAGE(B6:D6)"})); + assert!((v["result"].as_f64().unwrap() - (1950.5 + 1836.25 + 1902.0) / 3.0).abs() < 1e-9); +} + +// ---------- serve loop + remote ---------- + +#[test] +fn serve_loop_over_buffers() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + "garbage\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"evaluate_formula","arguments":{"formula":"=2*21"}}}"#, + "\n" + ); + let mut out = Vec::new(); + let mut s = server(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[1]["error"]["code"], -32700); + assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); +} + +#[test] +fn remote_backend_over_tcp() { + // A fake app: answers engine.execute by running a headless session; closes after the first + // request to exercise the reconnect path. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let mut h = Headless::new(); + for (i, stream) in listener.incoming().take(2).enumerate() { + let stream = stream.unwrap(); + let mut w = stream.try_clone().unwrap(); + let mut r = BufReader::new(stream); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let req: Value = serde_json::from_str(&line).unwrap(); + let reply = match h.call(req["method"].as_str().unwrap(), req["params"].clone()) { + Ok(v) => json!({"id": req["id"], "ok": true, "result": v}), + Err(e) => json!({"id": req["id"], "ok": false, "error": e}), + }; + w.write_all(format!("{reply}\n").as_bytes()).unwrap(); + if i == 0 { + break; // drop the first connection + } + } + } + }); + let addr = control_addr(&port.to_string()); + assert_eq!(addr, format!("127.0.0.1:{port}")); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0)); + // Second call reconnects transparently. + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A1"}))["value"], json!(7.0)); + let msg = fails(&mut s, "execute_command", json!({"command": "nope"})); + assert!(msg.contains("unknown command"), "{msg}"); + drop(s); + handle.join().unwrap(); + assert!(Remote::connect("127.0.0.1:1").is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 000000000..8921dc046 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,616 @@ +//! MCP tool definitions (names, descriptions, JSON Schemas) and their implementations on top of a +//! [`Backend`]. + +use serde_json::{Map, Value, json}; + +use crate::backend::Backend; +use crate::headless::NO_UI; + +/// The result of `tools/call`: MCP content blocks plus the `isError` flag. +#[derive(Clone, Debug, PartialEq)] +pub struct ToolResult { + pub content: Vec, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false } + } + pub fn json(v: &Value) -> Self { + Self::text(serde_json::to_string_pretty(v).unwrap_or_default()) + } + pub fn error(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true } + } + pub fn to_value(&self) -> Value { + json!({"content": self.content, "isError": self.is_error}) + } +} + +// ---------- schemas ---------- + +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn boolean(desc: &str) -> Value { + json!({"type": "boolean", "description": desc}) +} +fn int(desc: &str) -> Value { + json!({"type": "integer", "minimum": 0, "description": desc}) +} +fn enumeration(desc: &str, values: &[&str]) -> Value { + json!({"type": "string", "enum": values, "description": desc}) +} +fn params_schema() -> Value { + json!({"type": "object", "description": "Command parameters (see the `params` field of list_commands); omit or {} for defaults"}) +} +fn color(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { + json!({ + "name": name, + "title": title, + "description": desc, + "inputSchema": schema, + "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": false, "openWorldHint": false}, + }) +} + +const RANGE: &str = "A1-style range, e.g. \"A1:D10\", \"B:B\", \"Sheet2!A1:C3\""; +const CHART_TYPES: &[&str] = &[ + "column", + "bar", + "line", + "pie", + "doughnut", + "area", + "scatter", + "bubble", + "radar", + "combo", + "histogram", + "waterfall", + "funnel", + "treemap", + "sunburst", + "boxWhisker", + "stock", +]; +const BORDER_PRESETS: &[&str] = &[ + "bottom", + "top", + "left", + "right", + "none", + "all", + "outside", + "thickOutside", + "thickBottom", + "doubleBottom", + "topBottom", + "topThickBottom", + "topDoubleBottom", + "insideHorizontal", + "insideVertical", + "inside", + "diagonalDown", + "diagonalUp", +]; + +/// All tools, in `tools/list` order. +pub fn tool_definitions() -> Vec { + let empty = || obj(json!({}), &[]); + vec![ + // ----- generic ----- + tool( + "execute_command", + "Execute command", + "Run ANY SheetCraft command by id with JSON params — every ribbon/menu action is a command. Find ids and parameter \ + docs with list_commands. Examples: {\"command\":\"cell.set\",\"params\":{\"cell\":\"B2\",\"input\":\"=SUM(A1:A9)\"}}; \ + {\"command\":\"home.bold\",\"params\":{\"range\":\"A1:C1\"}}; {\"command\":\"sheet.insert\"}; \ + {\"command\":\"data.removeDuplicates\",\"params\":{\"range\":\"A1:C20\"}}. Most commands act on the current selection \ + when `range` is omitted. Programmatic calls never open dialogs: missing required params give an error.", + obj( + json!({"command": string("Command id, e.g. \"cell.set\", \"home.fillColor\", \"insert.chart\""), "params": params_schema()}), + &["command"], + ), + false, + ), + tool( + "list_commands", + "List commands", + "List SheetCraft commands: id, label, ribbon path, shortcut, a parameter description (`params`) and whether it can run \ + right now. Use it to discover what execute_command can do (e.g. search \"sort\", \"border\", \"chart\", \"sheet\").", + obj(json!({"search": string("Case-insensitive substring matched against id, label and ribbon path")}), &[]), + true, + ), + // ----- workbook ----- + tool( + "inspect_workbook", + "Inspect workbook", + "Summary of the active workbook: title, path, dirty flag, sheets (name, used range, cell count, tables, charts, merges, \ + filters, freeze panes), defined names, current selection and active cell, calculation mode and undo/redo history.", + empty(), + true, + ), + tool( + "read_range", + "Read range", + "Read cell values from a range as rows (2-D array). Numbers/booleans/strings are returned as JSON values, empty cells as \ + null, errors as {\"error\":\"#DIV/0!\"}. Without `range`, reads the sheet's used range. formulas:true returns formula \ + text (\"=SUM(B2:B4)\") for formula cells; formatted:true returns the displayed text (\"$1,234.00\", \"12%\").", + obj( + json!({ + "range": string(RANGE), + "sheet": string("Sheet name (default: the active sheet)"), + "formulas": boolean("Return formulas instead of computed values for formula cells"), + "formatted": boolean("Return display text with number formats applied"), + }), + &[], + ), + true, + ), + tool( + "write_range", + "Write range", + "Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \ + and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \ + input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", + obj( + json!({ + "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), + "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, + }), + &["range", "values"], + ), + false, + ), + tool( + "set_cell", + "Set cell", + "Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \ + Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.", + obj( + json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}), + &["cell", "input"], + ), + false, + ), + tool( + "get_cell", + "Get cell", + "Details of one cell: computed value and type, formula, display text, raw input, spill range, style, merge, comment and hyperlink.", + obj(json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\"")}), &["cell"]), + true, + ), + tool( + "evaluate_formula", + "Evaluate formula", + "Evaluate a formula against the active sheet WITHOUT changing any cell, e.g. \"=SUM(B2:B10)/COUNT(B2:B10)\" or \ + \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN().", + obj( + json!({"formula": string("Formula text, with or without the leading ="), "cell": string("Evaluate as if in this cell (default: active cell)")}), + &["formula"], + ), + true, + ), + tool( + "select", + "Select", + "Set the selection (what commands without a `range` param act on). Multiple areas with commas: \"A1:B2,D4\". \ + A \"Sheet2!A1\" prefix switches sheets.", + obj( + json!({"range": string(RANGE), "active": string("Active cell inside the selection (default: top-left of the last area)")}), + &["range"], + ), + false, + ), + tool( + "format_range", + "Format range", + "Apply several formats to a range in one call: bold, italic, underline, font name/size, font color, fill color, number \ + format, horizontal alignment, wrap, borders. Colors are \"#RRGGBB\" (fontColor also \"auto\", fillColor also \"none\"). \ + numberFormat is a name (General, Number, Currency, Accounting, Comma, Percentage, Short Date, Long Date, Time, Fraction, \ + Scientific, Text) or a format code (\"#,##0.00\", \"0%\", \"yyyy-mm-dd\"). Each property runs one command; the result \ + lists them.", + obj( + json!({ + "range": string(RANGE), + "bold": boolean("Bold on/off"), + "italic": boolean("Italic on/off"), + "underline": boolean("Single underline on/off"), + "fontName": string("Font family, e.g. \"Calibri\""), + "fontSize": {"type": "number", "minimum": 1, "maximum": 409, "description": "Font size in points"}, + "fontColor": color("Text color \"#RRGGBB\" or \"auto\""), + "fillColor": color("Background color \"#RRGGBB\" or \"none\""), + "numberFormat": string("Number format name or code"), + "horizontalAlign": enumeration("Horizontal alignment", &["left", "center", "right", "justify"]), + "wrapText": boolean("Wrap text on/off"), + "borders": { + "description": "A border preset name, or {preset, style?, color?}", + "oneOf": [ + enumeration("Border preset", BORDER_PRESETS), + obj(json!({ + "preset": enumeration("Border preset", BORDER_PRESETS), + "style": string("thin|medium|thick|dashed|dotted|double|hair…"), + "color": color("Border color \"#RRGGBB\""), + }), &["preset"]), + ], + }, + }), + &["range"], + ), + false, + ), + tool( + "insert_chart", + "Insert chart", + "Insert a chart from a data range (first row/column used as series names and categories, like Excel). Returns the chart id.", + obj( + json!({ + "range": string("Source data range including headers, e.g. \"A1:C6\""), + "type": enumeration("Chart type", CHART_TYPES), + "subtype": enumeration("Variant (optional)", &["clustered", "stacked", "stacked100", "markers"]), + "title": string("Chart title"), + "at": string("Top-left anchor cell (default: right of the data)"), + }), + &["range", "type"], + ), + false, + ), + tool( + "create_table", + "Create table", + "Format a range as an Excel table (structured references, banded rows, filter buttons). The first row is the header by default.", + obj( + json!({ + "range": string("Range including the header row, e.g. \"A1:D20\""), + "style": string("Table style, e.g. \"TableStyleMedium2\" (default), \"TableStyleLight9\""), + "header": boolean("First row holds headers (default true)"), + "name": string("Table name (default Table1, Table2…)"), + }), + &["range"], + ), + false, + ), + tool( + "sort_range", + "Sort range", + "Sort a range by one or more columns. keys: [{\"column\":\"B\",\"order\":\"desc\"}, …] or just [\"B\"]. Without `range` the \ + current region around the selection is sorted; header detection follows Excel unless `header` is given.", + obj( + json!({ + "range": string(RANGE), + "keys": { + "type": "array", + "minItems": 1, + "description": "Sort levels, most significant first", + "items": {"oneOf": [ + string("Column letter, e.g. \"B\" (ascending)"), + obj(json!({"column": string("Column letter, e.g. \"B\""), "order": enumeration("Sort order", &["asc", "desc"])}), &["column"]), + ]}, + }, + "header": boolean("The first row is a header (kept in place)"), + }), + &["keys"], + ), + false, + ), + tool( + "filter", + "Filter", + "Filter rows by a column (turns on AutoFilter on the current region, or `range`, when needed). Give `values` (keep rows whose \ + cell text is one of them), `custom` ({op:\">\", value:\"10\", op2?, value2?, and?}), `top` ({count, bottom?, percent?}), or \ + clear:true to remove this column's filter.", + obj( + json!({ + "column": string("Column letter, e.g. \"C\""), + "range": string("Data range to filter (default: the current region of the selection)"), + "values": {"type": "array", "items": {"type": ["string", "number", "boolean"]}, "description": "Values to keep"}, + "custom": {"type": "object", "description": "Custom criteria: {op: \"=\"|\"<>\"|\">\"|\">=\"|\"<\"|\"<=\"|\"beginsWith\"|\"contains\"…, value, op2?, value2?, and?: bool}"}, + "top": {"type": "object", "description": "Top/bottom N: {count, bottom?: bool, percent?: bool}"}, + "blanks": boolean("Also keep blank cells"), + "clear": boolean("Clear this column's filter"), + }), + &["column"], + ), + false, + ), + // ----- files ----- + tool( + "open_workbook", + "Open workbook", + "Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.", + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_workbook", + "Save workbook", + "Save the active workbook. The format follows the extension (.xlsx default; .csv/.tsv save the active sheet; .json; .html). \ + Without `path` saves to the workbook's current file.", + obj(json!({"path": string("Destination path")}), &[]), + false, + ), + tool( + "new_workbook", + "New workbook", + "Create a new workbook and make it active: blank, or a built-in sample (\"budget\", \"sales\", \"grades\").", + obj(json!({"sample": enumeration("Sample workbook", &["budget", "sales", "grades"])}), &[]), + false, + ), + tool( + "undo", + "Undo", + "Undo the last change(s) to the active workbook.", + obj(json!({"steps": int("Number of steps (default 1)")}), &[]), + false, + ), + tool("redo", "Redo", "Redo the last undone change(s).", obj(json!({"steps": int("Number of steps (default 1)")}), &[]), false), + tool( + "screenshot", + "Screenshot", + "Save a PNG screenshot of the SheetCraft window to `path`. Desktop app only (`sheetcraft-cli mcp --connect 7979`).", + obj(json!({"path": string("Output PNG path")}), &["path"]), + true, + ), + tool( + "list_functions", + "List functions", + "List worksheet functions: name, category, signature and description. Filter with `search` (name or description) or `category`.", + obj( + json!({"search": string("Substring of the name or description"), "category": string("Category, e.g. \"Math & Trig\", \"Lookup & Reference\"")}), + &[], + ), + true, + ), + ] +} + +// ---------- argument validation ---------- + +fn type_matches(t: &str, v: &Value) -> bool { + match t { + "string" => v.is_string(), + "number" => v.is_number(), + "integer" => v.is_u64() || v.is_i64(), + "boolean" => v.is_boolean(), + "array" => v.is_array(), + "object" => v.is_object(), + "null" => v.is_null(), + _ => true, + } +} + +/// Validates `args` against the tool's schema: unknown tool, missing required properties and +/// top-level type mismatches are JSON-RPC "invalid params" errors. +pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { + let defs = tool_definitions(); + let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?; + let schema = &def["inputSchema"]; + let Some(a) = args.as_object() else { return Err("tool arguments must be an object".into()) }; + for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { + if a.get(r).is_none_or(Value::is_null) { + return Err(format!("{name}: missing required argument `{r}`")); + } + } + let props = schema["properties"].as_object(); + for (k, v) in a { + let Some(p) = props.and_then(|p| p.get(k)) else { + return Err(format!("{name}: unknown argument `{k}`")); + }; + let ok = match &p["type"] { + Value::String(t) => type_matches(t, v), + Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)), + _ => true, + }; + if !ok { + return Err(format!("{name}: argument `{k}` must be of type {}", p["type"])); + } + if let Some(allowed) = p["enum"].as_array() + && !allowed.contains(v) + { + return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone()))); + } + } + Ok(()) +} + +// ---------- implementations ---------- + +type Args = Map; + +fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> { + a.get(k).and_then(Value::as_str).ok_or_else(|| format!("missing string argument `{k}`")) +} + +fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": cmd, "params": params})) +} + +/// Copies the listed keys (when present and non-null) into a params object. +fn pick(a: &Args, keys: &[&str]) -> Value { + Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect()) +} + +fn format_range(b: &mut dyn Backend, a: &Args) -> Result { + let range = req_str(a, "range")?; + let mut steps: Vec<(&str, Value)> = Vec::new(); + let r = json!(range); + if let Some(v) = a.get("bold").and_then(Value::as_bool) { + steps.push(("home.bold", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("italic").and_then(Value::as_bool) { + steps.push(("home.italic", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("underline").and_then(Value::as_bool) { + steps.push(("home.underline", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("fontName").and_then(Value::as_str) { + steps.push(("home.fontName", json!({"range": r, "name": v}))); + } + if let Some(v) = a.get("fontSize").and_then(Value::as_f64) { + steps.push(("home.fontSize", json!({"range": r, "size": v}))); + } + if let Some(v) = a.get("fontColor").and_then(Value::as_str) { + steps.push(("home.fontColor", json!({"range": r, "color": v}))); + } + if let Some(v) = a.get("fillColor").and_then(Value::as_str) { + steps.push(("home.fillColor", json!({"range": r, "color": v}))); + } + if let Some(v) = a.get("numberFormat").and_then(Value::as_str) { + steps.push(("home.numberFormat", json!({"range": r, "format": v}))); + } + if let Some(v) = a.get("horizontalAlign").and_then(Value::as_str) { + let id = match v { + "left" => "home.alignLeft", + "center" => "home.alignCenter", + "right" => "home.alignRight", + "justify" => "home.justify", + other => return Err(format!("horizontalAlign `{other}` must be left, center, right or justify")), + }; + steps.push((id, json!({"range": r}))); + } + if let Some(v) = a.get("wrapText").and_then(Value::as_bool) { + steps.push(("home.wrapText", json!({"range": r, "on": v}))); + } + match a.get("borders") { + None | Some(Value::Null) => {} + Some(Value::String(preset)) => steps.push(("home.borders", json!({"range": r, "preset": preset}))), + Some(Value::Object(o)) => { + let mut p = Value::Object(o.clone()); + p["range"] = r.clone(); + steps.push(("home.borders", p)); + } + Some(_) => return Err("`borders` must be a preset name or {preset, style?, color?}".into()), + } + if steps.is_empty() { + return Err("nothing to apply: give at least one of bold, italic, underline, fontName, fontSize, fontColor, fillColor, numberFormat, horizontalAlign, wrapText, borders".into()); + } + let mut applied = Vec::new(); + for (id, p) in steps { + exec(b, id, p).map_err(|e| format!("{id}: {e} (applied before the failure: {applied:?})"))?; + applied.push(id); + } + Ok(json!({"range": range, "applied": applied})) +} + +fn sort_range(b: &mut dyn Backend, a: &Args) -> Result { + let keys = a.get("keys").and_then(Value::as_array).ok_or("`keys` must be an array")?; + if keys.is_empty() { + return Err("`keys` needs at least one sort level".into()); + } + let mut norm = Vec::with_capacity(keys.len()); + for k in keys { + norm.push(match k { + Value::String(c) => { + // "B" or "B desc" + let mut it = c.split_whitespace(); + let col = it.next().unwrap_or_default(); + let order = match it.next().map(str::to_ascii_lowercase).as_deref() { + Some("desc" | "descending") => "desc", + _ => "asc", + }; + json!({"column": col, "order": order}) + } + Value::Object(o) => { + let mut o = o.clone(); + o.entry("order").or_insert(json!("asc")); + Value::Object(o) + } + _ => return Err("each sort key is a column letter or {column, order}".into()), + }); + } + let mut p = pick(a, &["range", "header"]); + p["keys"] = Value::Array(norm); + exec(b, "data.sort", p) +} + +fn filter(b: &mut dyn Backend, a: &Args) -> Result { + if let Some(r) = a.get("range").and_then(Value::as_str) { + exec(b, "selection.set", json!({"range": r}))?; + } + exec(b, "data.filterBy", pick(a, &["column", "values", "custom", "top", "blanks", "clear"])) +} + +fn save_workbook(b: &mut dyn Backend, a: &Args) -> Result { + if a.get("path").and_then(Value::as_str).is_none() { + let info = b.call("document.inspect", json!({}))?; + if info.get("path").is_none_or(Value::is_null) { + return Err("this workbook has never been saved: pass `path` (e.g. \"/tmp/book.xlsx\")".into()); + } + } + exec(b, "file.save", pick(a, &["path"])) +} + +fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { + if !b.has_ui() { + return Err(NO_UI.into()); + } + b.call("ui.screenshot", json!({"path": req_str(a, "path")?})) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { + match name { + "execute_command" => { + let cmd = req_str(a, "command")?; + let p = match a.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err("`params` must be an object".into()), + }; + exec(b, cmd, p) + } + "list_commands" => b.call("engine.commands", pick(a, &["search"])), + "inspect_workbook" => b.call("document.inspect", json!({})), + "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), + "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), + "set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])), + "get_cell" => exec(b, "cell.get", pick(a, &["cell"])), + "evaluate_formula" => { + let f = req_str(a, "formula")?; + let formula = if f.starts_with('=') { f.to_string() } else { format!("={f}") }; + let mut p = pick(a, &["cell"]); + p["formula"] = json!(formula); + let v = exec(b, "formulas.evaluate", p)?; + Ok(json!({"formula": formula, "result": v})) + } + "select" => exec(b, "selection.set", pick(a, &["range", "active"])), + "format_range" => format_range(b, a), + "insert_chart" => exec(b, "insert.chart", pick(a, &["range", "type", "subtype", "title", "at"])), + "create_table" => exec(b, "insert.table", pick(a, &["range", "style", "header", "name"])), + "sort_range" => sort_range(b, a), + "filter" => filter(b, a), + "open_workbook" => exec(b, "file.open", pick(a, &["path"])), + "save_workbook" => save_workbook(b, a), + "new_workbook" => exec(b, "file.new", pick(a, &["sample"])), + "undo" => exec(b, "edit.undo", pick(a, &["steps"])), + "redo" => exec(b, "edit.redo", pick(a, &["steps"])), + "screenshot" => screenshot(b, a), + "list_functions" => exec(b, "formulas.functions", pick(a, &["search", "category"])), + other => Err(format!("unknown tool `{other}`")), + } +} + +/// Runs one tool. Failures become `isError` results with a readable message. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { + let empty = Map::new(); + let a = match args { + Value::Object(o) => o, + Value::Null => &empty, + _ => return ToolResult::error("tool arguments must be a JSON object"), + }; + match dispatch(b, name, a) { + Ok(v) => ToolResult::json(&v), + Err(e) => ToolResult::error(format!("{name}: {e}")), + } +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b128b53ee..6c69dd667 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -114,28 +114,86 @@ pub enum CfValueKind { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum CfRule { /// Cell value compared with one or two formulas. - CellIs { op: CfOperator, a: String, b: Option, style: Box
{}