diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index dcd0192f..b01af052 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,13 +1,12 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner -# (GitHub has no FreeBSD runners). -# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the -# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is -# uploaded and no secrets are used. +# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script +# the release job runs, and checks the tarball, so a broken FreeBSD package fails here +# before it can fail a release. Nothing is uploaded and no secrets are used. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build takes one to several hours, so it runs nightly on main rather than on every push +# or dependency change: a drifted dependency still shows up within a day. PRs that change the +# FreeBSD packaging itself still run it. on: schedule: - cron: "17 2 * * *" @@ -29,36 +28,8 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test + package) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build, test and package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - # dejavu: the text tests need one real font, as the Ubuntu runner has. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - set -e - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked - # The release package, as release.yml builds it. Free the debug build's disk first. - rm -rf target/debug - CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + run-package-check: true + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..01cb9cd6 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 26fad875..38698902 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,58 +19,7 @@ concurrency: jobs: lint: - timeout-minutes: 15 - 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 packaging/linux/AppRun - - name: Server install script (Caddyfile handling) - run: bash packaging/server/install-test.sh - - name: AppImage AppRun desktop integration - run: bash packaging/linux/apprun-test.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)" - } - ./packaging/windows/check-icons.ps1 - - name: FreeBSD package layout (dry run, no build) - run: bash packaging/freebsd/package.sh --dry-run - - name: No template leftovers from other apps in package metadata - run: | - if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then - echo "::error::package metadata mentions another product (see above)" - exit 1 - fi - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml* - desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop - sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + with: + extra-checks-script: packaging/check-app-specific.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddf622d7..75f7a5fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "FigCraft v". See docs/releasing.md. -# -# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the -# `release` environment (restricted to the `release` branch), which holds the signing secrets; the -# rest build on any branch, so `gh workflow run release.yml --ref ` is a real dry run. -# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed -# build. Playbook: storytold/craftrules release/playbook.md. name: Release on: @@ -19,543 +11,34 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - FIGCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - # craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release - # embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build - # embeds Inter only, see crates/text/build.rs). An optional build input for everyone else. - # crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump - # deliberately; every build job checks out this commit. - CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 - jobs: - version: - timeout-minutes: 10 - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - 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 "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - timeout-minutes: 90 - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - 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 --locked -q -p xtask -- version set "$FIGCRAFT_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: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - # Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime, - # timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's - # stapled ticket. Strict when the signing secrets are present, warnings otherwise. - - name: Verify signatures and notarization - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/verify.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: - timeout-minutes: 90 - 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: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - 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 --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 5.0.2 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - - name: Build, sign, package - shell: pwsh - env: - 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 }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - 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: - timeout-minutes: 90 - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - 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: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_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/figcraft$|metainfo|applications' - ldd target/release/figcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - server: - timeout-minutes: 90 - name: Server Linux ${{ matrix.arch }} - needs: version - # The headless collaboration server (docs/server.md): figcraft-server--linux-.tar.gz - # with the binary, licences, docs/server.md and the deployment examples. Same runners as the - # app, so the same glibc floor; no craft-fonts (the server draws no text). - # shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli, - # when people host there. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04 } - - { arch: aarch64, runner: ubuntu-22.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-server-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Build and package (tar.gz) - run: packaging/server/package.sh - - name: Check the archive - run: | - tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt" - for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do - grep -E "$f" "$RUNNER_TEMP/server-contents.txt" - done - ldd target/release/figcraft-server - - uses: actions/upload-artifact@v4 - with: - name: release-server-linux-${{ matrix.arch }} - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - timeout-minutes: 90 - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small - # for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner. - runs-on: github-ubuntu-16core - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - 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 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - timeout-minutes: 90 - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - - 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 --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - # The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have - # no system fonts to fall back on, and the rest would push the wasm towards - # packaging/web/package.sh's 24 MiB gate. - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - 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 - - linux-riscv64: - timeout-minutes: 90 - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - release: - timeout-minutes: 10 - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server] - 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="FigCraft 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 run -p xtask -- version set X.Y.Z) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: figcraft + app-title: FigCraft + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + has-server: true + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a289df72..4cceb922 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,77 +24,10 @@ concurrency: 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 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/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: figcraft-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: figcraft-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 'FigCraft' - # figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed. - foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { - if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue } - 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 binary runs natively on ARM64 (the CLI when present, else the app's --version). - $cli = Join-Path $dir 'figcraft-cli.exe' - if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' } - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" } - Write-Output "ok $(Split-Path -Leaf $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 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: figcraft + app-dir: FigCraft diff --git a/Cargo.lock b/Cargo.lock index aead39d1..bc60687e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1734,6 +1735,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2820,6 +2827,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3204,6 +3220,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3264,6 +3290,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4574,6 +4611,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5726,6 +5774,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/README.md b/README.md index 0910bf9e..a072a06f 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/ROADMAP.md b/ROADMAP.md index ace7702e..2dcc2b9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI @@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. +- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +315,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | +| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | +| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | +| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | +| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | +| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 9b0f9b55..3e08474a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> { fn page_named(doc: &Document, name: Option<&str>) -> CliResult { match name { - None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())), + None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())), Some(n) => doc .pages .iter() @@ -333,17 +333,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } None => Session::new(), }; - for (i, (cmd, params)) in steps.iter().enumerate() { - let r = s.execute(cmd, params); - s.ui_requests.clear(); - match r { - Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), - Err(e) => { - println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); - return Ok(ExitCode::from(1)); - } + // One undo step and all-or-nothing, the way the same commands behave through `command.batch` + // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and + // a step that fails takes the whole script back instead of leaving the earlier steps applied. + // The result lines are held back until the script is through, so none of them reports work that + // was rolled back. + let mut results: Vec = Vec::with_capacity(steps.len()); + let mut failed_at = 0; + let outcome = s.grouped(&format!("run {script}"), |s| { + for (i, (cmd, params)) in steps.iter().enumerate() { + failed_at = i; + let v = s.batch_step(&results, i, cmd, params)?; + s.ui_requests.clear(); + results.push(v); } + Ok(()) + }); + if let Err(e) = outcome { + let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let i = failed_at; + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + eprintln!("the script's document changes were rolled back"); + return Ok(ExitCode::from(1)); + } + for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { + println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -380,6 +395,22 @@ mod tests { assert_eq!(sanitize(""), "layer"); } + #[test] + fn default_page_skips_leading_divider() { + let mut doc = Document::new("divider"); + let page = doc.first_page().unwrap(); + doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap(); + let divider = doc.add_page("- Divider"); + doc.pages.retain(|p| *p != divider); + doc.pages.insert(0, divider); + assert!(doc.is_divider(divider)); + assert_eq!(page_named(&doc, None).unwrap(), page); + assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider); + let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap(); + assert_eq!((pm.width(), pm.height()), (40, 30)); + assert!(render_page(&doc, divider, 1.0).is_err()); + } + #[test] fn export_depth_errors_preserve_conversion_destinations() { let mut doc = Document::new("export depth"); @@ -406,4 +437,62 @@ mod tests { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step + /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it + /// as "no parent" and the layer landed on the page — with every step reporting success. + #[test] + fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() { + let dir = temp_dir("run-step"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let top = doc.node(page).unwrap().children.clone(); + assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too"); + assert_eq!(doc.node(top[0]).unwrap().name, "Card"); + let inner = doc.node(top[0]).unwrap().children.clone(); + assert_eq!(inner.len(), 1, "the rectangle went inside the frame"); + assert_eq!(doc.node(inner[0]).unwrap().name, "child"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A step that fails takes the whole script back: nothing is written, and the failure says so. + #[test] + fn a_failed_step_leaves_no_output() { + let dir = temp_dir("run-rollback"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + // The failure is the script's, not the call's: the CLI reports it and exits non-zero. + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + assert!(!out.exists(), "a script that failed writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index c08ddc04..03190306 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))), ); let doc = s.doc().map_err(failed)?.clone(); - let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?; - table.put( - col, - "switch a card's variant (nested overrides kept)", - mean(5, |i| { - exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) - }), - ); - let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?; - table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); - table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); - let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?; - table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); - // A button glyph the drop really lands on: screens can overlap (text sizes differ without - // fonts), so ask the hit test the swap uses. - let now = s.doc().map_err(failed)?.clone(); - let page = s.page().map_err(failed)?; - let b = std::iter::once(bglyph) - .chain(inst.iter().filter_map(|i| { - let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; - (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? - })) - .filter_map(|g| now.world_bounds(g)) - .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()) - .ok_or_else(|| failed("no button glyph to drop on"))?; - let other = ds.icons.get(5).copied().unwrap_or(icon0); - table.put( - col, - "Alt-drop swap onto an instance", - timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), - ); - exec(&mut s, "edit.copy", json!({"ids": [nav]})); - table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + // Small workloads may not contain every instance type; the rows needing a missing one stay empty. + if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) { + table.put( + col, + "switch a card's variant (nested overrides kept)", + mean(5, |i| { + exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) + }), + ); + } else { + table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN); + } + let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav); + if let Some(nav) = nav { + table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); + table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); + } else { + table.put(col, "reset all changes on a nav", f64::NAN); + table.put(col, "detach a nav", f64::NAN); + } + if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) { + table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); + // A button glyph the drop really lands on: screens can overlap (text sizes differ without + // fonts), so ask the hit test the swap uses. + let now = s.doc().map_err(failed)?.clone(); + let page = s.page().map_err(failed)?; + let drop_at = std::iter::once(bglyph) + .chain(inst.iter().filter_map(|i| { + let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; + (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? + })) + .filter_map(|g| now.world_bounds(g)) + .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()); + let other = ds.icons.get(5).copied().unwrap_or(icon0); + table.put( + col, + "Alt-drop swap onto an instance", + match drop_at { + Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), + None => f64::NAN, + }, + ); + } else { + table.put(col, "push a button's changes to its main", f64::NAN); + table.put(col, "Alt-drop swap onto an instance", f64::NAN); + } + if let Some(nav) = nav { + exec(&mut s, "edit.copy", json!({"ids": [nav]})); + table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + } else { + table.put(col, "copy + paste a nav", f64::NAN); + } table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo())); table.put(col, "redo", mean(10, |_| _ = s.redo())); // As a library: publish it, use it from another file, publish a change, accept the update. - let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id())); + // One directory per run, so concurrent runs in a process don't delete each other's files. + static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id())); std::fs::create_dir_all(&dir).map_err(failed)?; let path = dir.join("design-system.figcraft").to_string_lossy().into_owned(); let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?; @@ -533,6 +555,14 @@ mod tests { assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows); } + #[test] + fn measures_components_with_a_single_instance() { + let mut t = super::Table { rows: Vec::new() }; + super::measure_components(&mut t, 0, 1).unwrap(); + assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows); + assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows); + } + #[test] fn measures_a_small_document() { let mut t = super::Table { rows: Vec::new() }; diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index bd587b76..101c7815 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -135,9 +135,23 @@ impl Config { /// abuse contact, or one whose clients would all count as its proxy). pub fn warnings(&self) -> Vec { let mut out = Vec::new(); + let client_max = NetLimits::default().max_server_message >> 20; + if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) { + out.push(format!( + "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened", + self.file_mb(), + client_max - 1 + )); + } if !self.open_publish { return out; } + if self.file_mb() > 64 { + out.push(format!( + "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server", + self.file_mb() + )); + } if self.operator_contact.trim().is_empty() { out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into()); } @@ -253,8 +267,9 @@ impl Config { } } - /// Largest message a client may send when open to the public: a whole file (or plugin bundle) - /// and a margin, not the core's 64 MiB. + /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the + /// public this is also the cap (strangers' files stay small); a private server never goes below + /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it. fn open_message(&self) -> usize { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } @@ -265,9 +280,9 @@ impl Config { fn core(&self) -> figcraft_collab::Limits { let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; if !self.open_publish { - return base; + return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } - let max = self.open_message().min(base.max_message_bytes); + let max = self.open_message(); let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } } @@ -279,21 +294,24 @@ impl Config { pub fn net(&self) -> NetLimits { let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() }; if !self.open_publish { - return base; + let max = base.max_client_message.max(self.open_message()); + return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base }; } - let max = self.open_message().min(base.max_client_message); + let max = self.open_message(); // Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an - // eighth of each, and its biggest consumers are dropped first. - let (queued, reading) = (256usize << 20, 192usize << 20); + // eighth of each, and its biggest consumers are dropped first. Each still fits one whole + // message, or a file as large as `max_file_mb` could never arrive. + let one = max.saturating_add(1 << 20); + let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8))); NetLimits { max_per_address: self.max_connections_per_ip, max_client_message: max, - max_queued_bytes: max.saturating_add(1 << 20), + max_queued_bytes: one, max_queued_block: queued / 8, max_queued_total: queued, - max_reading_block: max.saturating_add(1 << 20).min(reading / 8), + max_reading_block: one.min(reading / 8), max_reading_total: reading, - max_in_flight: 128 << 20, + max_in_flight: (128usize << 20).max(one), reserve_for_sessions: self.max_connections / 8, request_idle: std::time::Duration::from_secs(20), ..base diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index f58f25b2..af6e0f99 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -8,8 +8,10 @@ //! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. //! //! Compaction writes the next generation's snapshot, then an empty log of that generation. A log -//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it -//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). +//! whose generation isn't the snapshot's is usually from before a compaction that was cut short +//! (what it holds is in the snapshot already), but no checksum covers either generation, so the +//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its +//! first damaged record (a write cut short). use std::fs::{self, File}; use std::io::{self, Read, Write}; diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index fea2667e..978d9e8e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly"); assert_eq!(open.net().reserve_for_sessions, 512 / 8); assert_eq!(open.net().max_client_message, 17 << 20); - assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private"); + assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin"); let w = open.warnings().join("\n"); assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}"); let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open }; @@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() { assert!(!format!("{info:?}").contains(&k)); server.stop(); } + +#[test] +fn files_above_64_mib_fit_every_limit() { + // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must + // follow it, or the upload is dropped by the transport before the server can say why. + for open_publish in [false, true] { + let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() }; + let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20); + assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message); + assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}"); + assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); + assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); + } + // A client takes such a file too, but caps what any server may make it buffer at 1 GiB. + assert_eq!(NetLimits::default().max_server_message, 1 << 30); +} + +#[test] +fn large_files_warn_the_operator() { + let public_url = Some("wss://figcraft.example.com/".into()); + let warns = |open_publish, mb| { + Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join( + " +", + ) + }; + assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64)); + assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65)); + assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023)); + assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024)); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 4886bc88..ea9bc626 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -347,14 +347,14 @@ impl Store { let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let (directory, problems) = Directory::open(root, cfg.directory); + let (directory, startup_problems) = Directory::open(root, cfg.directory); let abuse = Abuse::new(cfg.hosting.abuse); let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), - problems, + problems: Vec::new(), directory, abuse, served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST), @@ -368,6 +368,9 @@ impl Store { free: None, _lock: lock, }; + for problem in startup_problems { + store.note(problem); + } store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); @@ -385,7 +388,7 @@ impl Store { match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) { Ok(list) => list, Err(e) => { - self.problems.push(e); + self.note(e); Vec::new() } } @@ -394,7 +397,7 @@ impl Store { fn scan(&mut self, dir: &Path) { let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())), + Err(e) => return self.note(format!("can't list {}: {e}", dir.display())), }; let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); ids.sort(); @@ -403,18 +406,18 @@ impl Store { continue; } if !valid_id(&id) { - self.problems.push(format!("skipped {}: not a file folder of this server", path.display())); + self.note(format!("skipped {}: not a file folder of this server", path.display())); continue; } if self.files.len() >= self.cfg.max_files { - self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); + self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); continue; } match self.check(&path) { Ok(entry) => { self.files.insert(id, entry); } - Err(why) => self.problems.push(format!("skipped file {id}: {why}")), + Err(why) => self.note(format!("skipped file {id}: {why}")), } } } @@ -437,7 +440,7 @@ impl Store { && let Ok(log) = disk::read_log(&bytes) && log.generation != generation { - log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); Ok(Entry { meta, links, log: None, bytes, forced: 0 }) @@ -1123,8 +1126,32 @@ impl Store { fresh_log = true; } } - // From before a compaction that was cut short: the snapshot has it all. - Ok(_) => {} + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), } for i in issues { diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs new file mode 100644 index 00000000..d728908d --- /dev/null +++ b/apps/figcraft-server/tests/header_recovery.rs @@ -0,0 +1,139 @@ +//! A generation field damaged after an abrupt stop must not make the server drop a log that still +//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the +//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short +//! still restores silently. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; +use figcraft_server::config::Config; +use figcraft_server::disk::{self, LOG, SNAPSHOT}; +use figcraft_server::store::{FILES, Store}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn page_name(server: &Server) -> String { + let doc = server.document(); + doc.node(doc.first_page().unwrap()).unwrap().name.clone() +} + +/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then +/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state. +fn renamed_and_stopped(data: &Path) -> (String, Server) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap(); + let (info, _, _) = store.publish("Fixture", &snapshot).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") }; + let (_, messages) = server.join_as(hello, Access::Edit).unwrap(); + let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") }; + let mut client = Client::join(welcome).unwrap(); + store.changed(&id, &mut server); + let mut edited = client.document().clone(); + let page = edited.first_page().unwrap(); + edited.node_mut(page).unwrap().name = "Kept".into(); + let change = client.edit(&edited).unwrap().unwrap(); + assert!(!server.receive(client.id(), change).is_empty()); + store.changed(&id, &mut server); + assert!(store.flush().is_empty(), "the edit must reach disk"); + assert_eq!(page_name(&server), "Kept"); + let dir = data.join(FILES).join(&id); + assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty()); + assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log"); + drop(store); + (id, server) +} + +/// Reopen the data directory: the file's first page name and the problems reported. +fn reopen(data: &Path, id: &str) -> (String, Vec) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let server = store.open(id).unwrap(); + (page_name(&server), store.problems().to_vec()) +} + +fn flip(path: &Path, at: usize) { + let mut bytes = fs::read(path).unwrap(); + bytes[at] ^= 1; + fs::write(path, bytes).unwrap(); +} + +/// The generation field's first byte in both headers (after the magic and the format byte). +const GENERATION_AT: usize = 5; + +#[test] +fn an_intact_log_restores_its_edits_without_a_report() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn a_damaged_log_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let log = fixture.0.join(FILES).join(&id).join(LOG); + flip(&log, GENERATION_AT); + // The records themselves still read: only the header's generation changed. + let read = disk::read_log(&fs::read(&log).unwrap()).unwrap(); + assert!(read.damaged.is_none() && !read.entries.is_empty()); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + // And it stays: the recovered edit is now in the file's snapshot. + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT); + flip(&snapshot, GENERATION_AT); + assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum"); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_log_format_is_still_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + flip(&fixture.0.join(FILES).join(&id).join(LOG), 4); + let (_, problems) = reopen(&fixture.0, &id); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}"); +} + +#[test] +fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() { + let fixture = Fixture::new(); + let (id, server) = renamed_and_stopped(&fixture.0); + // The next snapshot was written (it holds the rename); the old log wasn't replaced yet. + let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap(); + fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap(); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}"); +} diff --git a/apps/figcraft-server/tests/problem_retention.rs b/apps/figcraft-server/tests/problem_retention.rs new file mode 100644 index 00000000..221eaa6e --- /dev/null +++ b/apps/figcraft-server/tests/problem_retention.rs @@ -0,0 +1,40 @@ +use figcraft_server::config::Config; +use figcraft_server::store::{FILES, Store}; + +#[test] +fn startup_problem_retention() { + let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id())); + assert!(!root.exists()); + let result = (|| -> Result<(), String> { + for count in [3u32, 200, 201] { + let data = root.join(count.to_string()); + for i in 0..count { + std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?; + } + let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() }; + cfg.check()?; + let store = Store::open(&data, cfg.store())?; + let expected = (count as usize).min(200); + let problems = store.problems().to_vec(); + let no_loaded_files = store.is_empty(); + drop(store); + if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files { + return Err("unexpected diagnostic or loaded file".into()); + } + if problems.len() != expected { + return Err(format!("expected {expected} retained problems, got {}", problems.len())); + } + // The latest problems stay, in scan order: the first folder's is the one dropped. + let first_kept = count as usize - expected; + for (n, p) in problems.iter().enumerate() { + let id = format!("{:016x}", first_kept + n); + if !p.contains(&id) { + return Err(format!("problem {n} should be about {id}: {p}")); + } + } + } + Ok(()) + })(); + std::fs::remove_dir_all(&root).unwrap(); + assert!(result.is_ok(), "{}", result.unwrap_err()); +} diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 587fd60b..7eed2ffb 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -14,7 +14,16 @@ //! join from other machines. //! //! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. +#[cfg(any(target_arch = "wasm32", test))] +mod query; +#[cfg(test)] +mod query_tests; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs new file mode 100644 index 00000000..befeb36b --- /dev/null +++ b/apps/figcraft-web/src/query.rs @@ -0,0 +1,15 @@ +//! The page's URL: the invite link its fragment names. + +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs new file mode 100644 index 00000000..261ebaeb --- /dev/null +++ b/apps/figcraft-web/src/query_tests.rs @@ -0,0 +1,29 @@ +//! `#join=` in the page's URL. + +use crate::query::join_link; + +#[test] +fn join_takes_the_rest_of_the_fragment() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); +} + +#[test] +fn no_join_without_the_entry() { + assert_eq!(join_link(""), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 8cadb013..29213621 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,13 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { + let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); + figcraft_ui_egui::live::open_invite(&mut fig, &link); + } Ok(Box::new(WebShell { fig })) }), ) diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da1..fcc5d03a 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true, features = ["image-data"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs index 6b792094..41ae5cc3 100644 --- a/apps/figcraft/src/apple_events.rs +++ b/apps/figcraft/src/apple_events.rs @@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { /// Open `paths` as File › Open does; a file that fails shows its error and the others still open. pub fn open_documents(fig: &mut FigApp, paths: Vec) { for p in paths { - if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() { fig.ui.home = false; } } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 995240f5..1c91840f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s } } } - Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}), + Err(e) => { + // A line that isn't JSON ends the connection: a web page can make the browser send + // an HTTP request here, and its header lines must not let the JSON lines of its body + // through as commands. + writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?; + break; + } }; writeln!(out, "{reply}")?; } diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 2e0a7b48..008170fb 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() { let pending = queue(); let q = pending.clone(); let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); - client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); + client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); let mut reader = BufReader::new(client); - assert_eq!(reply(&mut reader)["ok"], false); let start = Instant::now(); let r = loop { if let Some(r) = pending.lock().unwrap().pop() { @@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() { worker.join().unwrap().unwrap(); } +#[test] +fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() { + // What a browser sends when a web page posts to the port: the JSON line in its body never runs. + let (mut client, server) = pair(); + let pending = queue(); + let q = pending.clone(); + let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); + let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n"; + let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len()); + client.write_all(http.as_bytes()).unwrap(); + let mut reader = BufReader::new(client); + assert_eq!(reply(&mut reader)["ok"], false); + assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed"); + worker.join().unwrap().unwrap(); + assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched"); +} + #[test] fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { let (mut client, server) = pair(); @@ -144,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // The server closes this connection after answering an invalid request, and shutting down + // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux + // returns Ok. Closing the client is what the worker is waiting for, so the result is moot: + // the other shutdowns in this file run while the peer is still open and stay checked. + let _ = client.shutdown(Shutdown::Both); workers.pop().unwrap().join().unwrap(); } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..6930f44d 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,12 +223,47 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// How long a clipboard read may wait on the app that copied (Linux and the BSDs). +#[cfg(all(unix, not(target_os = "macos")))] +const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle +/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no +/// timeout of its own, so an app that hangs must not hang the window too. +#[cfg(all(unix, not(target_os = "macos")))] +fn read_clipboard(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c))); + }); + rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten() +} + +/// Read the clipboard (the system's own reads don't wait on the app that copied). +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read_clipboard(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + read(c) +} + +/// Native Save as filters; other callers keep their existing combined filter. +fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { + if exts == ["figcraft", "fig"] { + vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])] + } else { + vec![("File", exts)] + } +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) + let mut dialog = dialog(title).set_file_name(name); + for (label, extensions) in save_filters(exts) { + dialog = dialog.add_filter(label, extensions); + } + picked(dialog.save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { picked(dialog(title).pick_folder()) @@ -239,7 +274,10 @@ impl Services for Desktop { } } fn clipboard_get_text(&mut self) -> Option { - self.clipboard.as_mut()?.get_text().ok() + read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) + } + fn clipboard_get_html(&mut self) -> Option { + read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok()) } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { @@ -247,7 +285,7 @@ impl Services for Desktop { } } fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { - let img = self.clipboard.as_mut()?.get_image().ok()?; + let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?; let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); // Refuse absurd sizes (and mismatched buffers) rather than allocate. if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { @@ -297,3 +335,24 @@ pub fn window_icon() -> Option { let (w, h) = img.dimensions(); Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) } + +#[cfg(test)] +mod save_filter_tests { + use super::save_filters; + + #[test] + fn native_and_fig_save_filters_are_separate_with_native_first() { + let extensions = ["figcraft", "fig"]; + assert_eq!( + save_filters(&extensions), + vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])] + ); + } + + #[test] + fn other_save_filters_keep_one_combined_group() { + for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] { + assert_eq!(save_filters(extensions), vec![("File", extensions)]); + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index e744e31c..ace49f7b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -38,4 +38,18 @@ real_name = "Justin John" [people.Losminek] [people.RevDonz] + +[people.Nael-Nathanael] +sync_github_name = true + +[people.volkovskey] +real_name = "Andrii Volkov" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.krinry] +display_name = "krinry" sync_github_name = true diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c3111876..a0647fa6 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,7 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document). + /// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB, + /// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server + /// (any link can name one) can't make the app or a browser tab buffer more than that. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -61,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: figcraft_collab::Limits::default().max_message_bytes, + max_server_message: 1 << 30, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d0bae9c..d6859a51 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; mod blend; mod hsx; mod icc; +mod named; mod profile; pub use blend::BlendMode; pub use hsx::*; @@ -139,7 +140,13 @@ impl Color { Some(Self::from_rgba8(r, g, b, a)) } - /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names. + /// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name + /// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque). + pub fn from_hex_or_name(s: &str) -> Option { + Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255))) + } + + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`. pub fn parse_css(s: &str) -> Option { let t = s.trim().to_ascii_lowercase(); if t.starts_with('#') { @@ -166,21 +173,10 @@ impl Color { let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } - let named = match t.as_str() { - "black" => Color::BLACK, - "white" => Color::WHITE, - "transparent" => Color::TRANSPARENT, - "red" => Color::from_rgb8_const(255, 0, 0), - "green" => Color::from_rgb8_const(0, 128, 0), - "lime" => Color::from_rgb8_const(0, 255, 0), - "blue" => Color::from_rgb8_const(0, 0, 255), - "yellow" => Color::from_rgb8_const(255, 255, 0), - "orange" => Color::from_rgb8_const(255, 165, 0), - "purple" => Color::from_rgb8_const(128, 0, 128), - "gray" | "grey" => Color::from_rgb8_const(128, 128, 128), - _ => return Self::from_hex(&t), - }; - Some(named) + if t == "transparent" { + return Some(Color::TRANSPARENT); + } + Self::from_hex_or_name(&t) } /// CSS `rgba(...)` / hex text for copy-as-CSS. diff --git a/crates/color/src/named.rs b/crates/color/src/named.rs new file mode 100644 index 00000000..882472cc --- /dev/null +++ b/crates/color/src/named.rs @@ -0,0 +1,197 @@ +//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as +//! `FF0000`. + +/// Every CSS named colour except `transparent`, sorted by name for binary search. +const NAMED: [(&str, [u8; 3]); 148] = [ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("aqua", [0, 255, 255]), + ("aquamarine", [127, 255, 212]), + ("azure", [240, 255, 255]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("burlywood", [222, 184, 135]), + ("cadetblue", [95, 158, 160]), + ("chartreuse", [127, 255, 0]), + ("chocolate", [210, 105, 30]), + ("coral", [255, 127, 80]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("crimson", [220, 20, 60]), + ("cyan", [0, 255, 255]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkorange", [255, 140, 0]), + ("darkorchid", [153, 50, 204]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deepskyblue", [0, 191, 255]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("firebrick", [178, 34, 34]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("fuchsia", [255, 0, 255]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("goldenrod", [218, 165, 32]), + ("gray", [128, 128, 128]), + ("green", [0, 128, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [128, 128, 128]), + ("honeydew", [240, 255, 240]), + ("hotpink", [255, 105, 180]), + ("indianred", [205, 92, 92]), + ("indigo", [75, 0, 130]), + ("ivory", [255, 255, 240]), + ("khaki", [240, 230, 140]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lightblue", [173, 216, 230]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightsalmon", [255, 160, 122]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightyellow", [255, 255, 224]), + ("lime", [0, 255, 0]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("maroon", [128, 0, 0]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumpurple", [147, 112, 219]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navy", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olive", [128, 128, 0]), + ("olivedrab", [107, 142, 35]), + ("orange", [255, 165, 0]), + ("orangered", [255, 69, 0]), + ("orchid", [218, 112, 214]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("paleturquoise", [175, 238, 238]), + ("palevioletred", [219, 112, 147]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("plum", [221, 160, 221]), + ("powderblue", [176, 224, 230]), + ("purple", [128, 0, 128]), + ("rebeccapurple", [102, 51, 153]), + ("red", [255, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("royalblue", [65, 105, 225]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("sienna", [160, 82, 45]), + ("silver", [192, 192, 192]), + ("skyblue", [135, 206, 235]), + ("slateblue", [106, 90, 205]), + ("slategray", [112, 128, 144]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("springgreen", [0, 255, 127]), + ("steelblue", [70, 130, 180]), + ("tan", [210, 180, 140]), + ("teal", [0, 128, 128]), + ("thistle", [216, 191, 216]), + ("tomato", [255, 99, 71]), + ("turquoise", [64, 224, 208]), + ("violet", [238, 130, 238]), + ("wheat", [245, 222, 179]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellowgreen", [154, 205, 50]), +]; + +/// Longest name in [`NAMED`]; anything longer can't match. +const LONGEST: usize = 20; + +/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so +/// `Light Blue`, `light-blue` and `lightblue` are the same colour. +pub fn lookup(s: &str) -> Option<[u8; 3]> { + let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::().to_ascii_lowercase(); + if key.is_empty() || key.len() > LONGEST { + return None; + } + NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST)); + } + + #[test] + fn every_name_is_found() { + for (name, rgb) in NAMED { + assert_eq!(lookup(name), Some(rgb), "{name}"); + } + } + + #[test] + fn lookup_normalises() { + assert_eq!(lookup("Red"), Some([255, 0, 0])); + assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237])); + assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210])); + assert_eq!(lookup(""), None); + assert_eq!(lookup("transparent"), None); + assert_eq!(lookup("reddish"), None); + assert_eq!(lookup("rédé"), None); + assert_eq!(lookup(&"a".repeat(10_000)), None); + } +} diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index facb3415..68a838e0 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -17,6 +17,23 @@ fn css_parsing() { assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]); assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00"); assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE); + assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399"); + assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT); +} + +#[test] +fn colour_fields_take_names() { + assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000"); + assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED"); + assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]); + // Hex wins: every digit string still reads as hex. + assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC"); + assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF"); + // Names are opaque; `transparent` is not a field colour. + assert!(Color::from_hex_or_name("transparent").is_none()); + for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] { + assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}"); + } } #[test] diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..2a7b42a6 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool { } } +/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path +/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance). +const STROKE_REACH: f64 = 2.5; + /// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where /// its outline is. fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool { @@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), NodeKind::Line => { - let half = (n.stroke.weight / 2.0).max(tol_l); + let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH); lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half } NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { @@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { return true; } - let reach = (n.stroke.max_weight() / 2.0).max(tol); + // Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one. + let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible); + let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol }); if !path.bounding_box().inflate(reach, reach).contains(lp) { return false; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 513800e3..07700e8c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,17 +77,19 @@ pub enum DocError { pub type DocResult = Result; -/// The set of touched node ids, and whether any layer is a mask (worked out when first asked -/// after a change: hit testing skips its mask checks without one). Never part of equality or +/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks +/// without one) and the largest background blur radius (renders keep that much overlap between +/// their bands), both worked out when first asked after a change. Never part of equality or /// serialisation. #[derive(Clone, Debug, Default)] -pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock); +pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock, std::sync::OnceLock); impl Dirty { /// Record node `id` as touched (edited, added or removed). fn touch(&mut self, id: NodeId) { self.0.insert(id); self.1 = std::sync::OnceLock::new(); + self.2 = std::sync::OnceLock::new(); } } @@ -254,6 +256,11 @@ impl Document { *self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask)) } + /// The largest background blur or glass radius of any layer's effects (0 without any). + pub fn max_backdrop_radius(&self) -> f64 { + *self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max)) + } + /// Node ids touched so far (without clearing). pub fn dirty(&self) -> &std::collections::BTreeSet { &self.dirty.0 @@ -781,6 +788,23 @@ impl Document { self.paste_nodes(&src_nodes, &map, id, parent, index) } + /// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root + /// ids. One id map covers every copy, so references between the copied subtrees (a navigation + /// from one copied frame to another) point at the copies. + pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult> { + let mut map = BTreeMap::new(); + let mut trees = Vec::with_capacity(ids.len()); + for &id in ids { + let src_nodes: Vec = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect(); + for n in &src_nodes { + let nid = self.alloc_id(); + map.insert(n.id, nid); + } + trees.push((id, src_nodes)); + } + trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect() + } + /// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids /// remapped by `map`. Interactions, instance references and pattern sources pointing inside the /// subtree are remapped too. @@ -1100,8 +1124,14 @@ impl Document { /// mask with the layers above it up to the next mask, or a single layer. An auto layout frame /// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]). pub fn paint_runs(&self, n: &Node) -> Vec> { - let kids = &n.children; - let node = |i: usize| kids.get(i).and_then(|k| self.node(*k)); + let kids: Vec> = n.children.iter().map(|k| self.node(*k)).collect(); + Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z)) + } + + /// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run + /// first when `reverse_z`. + pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec> { + let node = |i: usize| kids.get(i).copied().flatten(); let mut runs = Vec::new(); let mut i = 0; while i < kids.len() { @@ -1114,7 +1144,7 @@ impl Document { runs.push(i..j); i = j; } - if n.layout.as_ref().is_some_and(|l| l.reverse_z) { + if reverse_z { runs.reverse(); } runs diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index 2ec8e91f..f4404dc7 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -143,7 +143,7 @@ impl Expr { return "…".into(); } match self { - Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()), + Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), Expr::Value(VariableValue::String(s)) => quote(s), // Exact numbers and `#` colours, so the text reads back as the same expression. Expr::Value(VariableValue::Number(n)) => format!("{n}"), @@ -178,6 +178,18 @@ impl Expr { } } +/// A variable as written in an expression: its bare name when that reads back as this variable, +/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or +/// `"q"` don't read back as literals or errors. +fn var_ref(vars: &Variables, v: &crate::Variable) -> String { + let this = Expr::var(v.id.clone()); + if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) { + return v.name.clone(); + } + let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id); + if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) } +} + /// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has /// spaces at its ends, quotes or brackets. fn name_arg(name: &str) -> String { diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index 52f909fd..e106121d 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -176,3 +176,22 @@ fn hostile_text_is_bounded() { } } } + +#[test] +fn names_that_look_like_literals_read_back_as_variables() { + for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") { + var.name = name.into(); + } + for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + } + // Plain names stay bare, and literals still read as literals. + let v = vars(); + assert_eq!(Expr::var("n").display(&v), "n-name"); + assert_eq!(parse("1"), n(1.0)); + assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d54bb29b..a5ead3b7 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() { assert_eq!(t.characters, ""); } +/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load) +/// must not overflow the arithmetic that shifts the runs along, and must not leave the +/// text unwritable. +#[test] +fn text_runs_past_the_text_never_overflow() { + let style = || TextStyle { font_style: "Bold".into(), ..Default::default() }; + for end in [usize::MAX, usize::MAX - 1, 1_000_000] { + let mut t = TextData::new("hi"); + t.runs = vec![StyleRun { start: 0, end, style: style() }]; + // Typing at the caret, and replacing a selection: both shift `end`. + t.replace_range(0, 0, "x"); + assert_eq!(t.characters, "xhi", "end {end}"); + t.replace_range(0, 3, "yo"); + assert_eq!(t.characters, "yo", "end {end}"); + // The text is still writable and the runs are back inside it. + t.replace_range(0, 0, "!"); + assert_eq!(t.characters, "!yo", "end {end}"); + assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs); + } +} + #[test] fn text_case() { assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World"); @@ -381,3 +402,65 @@ fn custom_easings() { assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } + +#[test] +fn text_style_updates_merge_equal_runs() { + let def = TextStyle { font_size: 14.0, ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(0, 2, &def, "S"); + t.link_text_style(2, 4, &def, "S"); + t.restyle(2, 4, |s| s.font_size = 24.0); + assert_eq!(t.runs.len(), 2); + t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() }); + let spans = t.spans(); + assert_eq!(spans.len(), 1); + assert_eq!((spans[0].0, spans[0].1), (0, 4)); + assert_eq!(spans[0].2.font_size, 24.0); + assert_eq!(spans[0].2.style_id.as_deref(), Some("S")); + + // Remapping two links to one id merges the runs. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &def, "B"); + t.map_text_styles(|_| Some("C".into())); + assert_eq!(t.spans().len(), 1); + assert_eq!(t.style_at(1).style_id.as_deref(), Some("C")); + + // Different values stay separate. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B"); + t.map_text_styles(|_| Some("C".into())); + let spans = t.spans(); + assert_eq!(spans.len(), 2); + assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0)); + + // Unlinking a run equal to the base drops it. + let mut t = TextData::new("ab"); + let base = t.style.clone(); + t.link_text_style(0, 1, &base, "S"); + t.map_text_styles(|_| None); + assert!(t.runs.is_empty()); +} + +#[test] +fn text_style_empty_range_links_and_unlinks_all() { + let def = TextStyle { font_style: "Bold".into(), ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(2, 2, &def, "S"); + assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold")); + t.unlink_text_style(2, 2); + assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold")); + // Partial ranges stay partial. + t.link_text_style(0, 4, &def, "S"); + t.unlink_text_style(1, 3); + assert_eq!(t.style_at(0).style_id.as_deref(), Some("S")); + assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none()); + assert_eq!(t.style_at(3).style_id.as_deref(), Some("S")); + // Empty text changes the base style. + let mut e = TextData::new(""); + e.link_text_style(0, 0, &def, "S"); + assert_eq!(e.style.style_id.as_deref(), Some("S")); + e.unlink_text_style(0, 0); + assert!(e.style.style_id.is_none()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..097dd189 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,6 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -562,7 +566,7 @@ impl TextData { } } - /// Drop empty/out-of-range runs and runs equal to the base style. + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. pub fn normalize(&mut self) { let n = self.char_count(); let base = self.style.clone(); @@ -571,6 +575,14 @@ impl TextData { r.end = r.end.min(n); } self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for r in std::mem::take(&mut self.runs) { + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; } /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). @@ -602,7 +614,9 @@ impl TextData { if p <= start { p } else if p >= end { - p - removed + inserted + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) } else { start + inserted } @@ -627,13 +641,13 @@ impl TextData { /// Paragraph `p`'s list settings. pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) } /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). fn fill_paragraphs(&mut self) { let n = self.paragraph_count(); - let fill = ParaList { list: self.list, indent: 0 }; + let fill = ParaList { list: self.list, ..ParaList::default() }; self.paragraphs.resize(n, fill); } @@ -647,17 +661,23 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with - /// no nesting folds back into `list`. + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + if pl.list == ListStyle::None { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); } if let Some(first) = self.paragraphs.first().copied() - && first.indent == 0 + && first == (ParaList { list: first.list, ..ParaList::default() }) && self.paragraphs.iter().all(|p| *p == first) { self.list = first.list; @@ -665,21 +685,24 @@ impl TextData { } } - /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. pub fn common_para_list(&self, start: usize, end: usize) -> Option { let (a, b) = self.para_span(start, end); let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p) == first).then_some(first) + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.take_text_style(def, id)); } /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.style_id = None); } @@ -770,4 +793,29 @@ mod case_tests { assert_eq!(st.variations.keys().collect::>(), ["wdth"]); assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..1165bd60 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs index a34c5036..050ab821 100644 --- a/crates/doc/src/vector_faces.rs +++ b/crates/doc/src/vector_faces.rs @@ -136,8 +136,8 @@ impl VectorNetwork { } let k = key(&face.loops); match (self.regions.iter().position(|r| key(&r.loops) == k), want) { - (Some(i), None | Some(false)) => { - self.regions.remove(i); + (Some(_), None | Some(false)) => { + self.regions.retain(|r| key(&r.loops) != k); Some(false) } (Some(_), Some(true)) => Some(true), @@ -525,4 +525,21 @@ mod tests { let _ = j.paint(Point::new(f64::NAN, 5.0)); assert!(interior_point(&BezPath::new()).is_none()); } + + #[test] + fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() { + let c = Point::new(50.0, 50.0); + let mut n = square(0.0, 0.0, 100.0); + n.regions.push(n.regions[0].clone()); + assert_eq!(n.paint(c), Some(false)); + assert!(!n.fill_contains(c)); + assert_eq!(n.region_at(c), None); + assert_eq!(n.paint(c), Some(true)); + assert!(n.fill_contains(c)); + + let mut e = square(0.0, 0.0, 100.0); + e.regions.push(e.regions[0].clone()); + assert_eq!(e.paint_to(c, Some(false)), Some(false)); + assert!(!e.fill_contains(c)); + } } diff --git a/crates/doc/src/vector_regions.rs b/crates/doc/src/vector_regions.rs index 1d1cd794..cc456bef 100644 --- a/crates/doc/src/vector_regions.rs +++ b/crates/doc/src/vector_regions.rs @@ -18,8 +18,9 @@ impl VectorNetwork { /// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken /// as stored. Missing segments are skipped. pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> { - let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() }; - let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) { + let mut existing = lp.iter().filter_map(|i| self.segments.get(*i)); + let Some(first) = existing.next() else { return Vec::new() }; + let forward_first = match existing.next() { Some(second) => first.b == second.a || first.b == second.b, None => true, }; @@ -102,10 +103,18 @@ impl VectorNetwork { (0..self.regions.len()).rev().find(|r| self.region_contains(*r, p)) } - /// Every point on region `r`'s loops (holes' too), each once, sorted. + /// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that + /// don't exist are left out). pub fn region_points(&self, r: usize) -> Vec { let Some(region) = self.regions.get(r) else { return Vec::new() }; - let mut out: Vec = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect(); + let mut out: Vec = region + .loops + .iter() + .flatten() + .filter_map(|i| self.segments.get(*i)) + .flat_map(|s| [s.a, s.b]) + .filter(|v| *v < self.vertices.len()) + .collect(); out.sort_unstable(); out.dedup(); out diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index d1e3d0d8..c09664b4 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() { assert_eq!(n.region_at(p(300.0, 100.0)), None); } +#[test] +fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() { + use figcraft_geom::Shape; + let mut n = VectorNetwork::default(); + square(&mut n, 0.0, 0.0, 200.0); + n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)]; + let control = n.region_path(0).unwrap(); + let control_walk = n.loop_walk(&[0, 1, 2]); + for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] { + n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)]; + assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}"); + assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}"); + assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}"); + let path = n.region_path(0).unwrap(); + assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}"); + assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}"); + } + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_path(0).is_none()); + assert!(!n.region_contains(0, p(100.0, 100.0))); +} + #[test] fn a_regions_points_are_listed_once() { let mut n = nested(); @@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() { } } +#[test] +fn region_points_leave_out_segment_ends_that_do_not_exist() { + let mut n = VectorNetwork::default(); + let ring = square(&mut n, 0.0, 0.0, 100.0); + n.regions = vec![region(vec![ring], WindingRule::NonZero)]; + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + n.segments[3] = Segment::line(3, 9); + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + assert!(n.region_path(0).is_some()); + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_points(0).is_empty()); +} + #[test] fn the_topology_key_ignores_moved_points_but_not_new_segments() { let mut n = nested(); diff --git a/crates/engine/src/al_keys_tests.rs b/crates/engine/src/al_keys_tests.rs index 1053997e..76256466 100644 --- a/crates/engine/src/al_keys_tests.rs +++ b/crates/engine/src/al_keys_tests.rs @@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() { assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column"); } +#[test] +fn single_item_wrapped_lines_move_and_drop_by_line() { + let mut s = Session::new(); + // Full-width items: one per row. + let row = al(&mut s, None, "horizontal", 160.0, 200.0); + set(&mut s, row, json!({"wrap": true})); + let k: Vec = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect(); + assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row"); + let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]); + assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0])))); + arrow(&mut s, &[k[1]], 0, -1); + assert_eq!(kids(&s, row), vec![k[1], k[0]]); + + // Full-height items in a vertical wrap: one per column. + let col = al(&mut s, None, "vertical", 200.0, 160.0); + set(&mut s, col, json!({"wrap": true})); + let c: Vec = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect(); + arrow(&mut s, &[c[2]], -1, 0); + assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]); +} + #[test] fn wrapped_rows_move_up_and_down_a_row() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0fb64816..a8901f24 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { let cmd = "instance.create"; let c = req_id(cmd, p, "component")?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { @@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { if !doc.contains(component) { return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); } - // Copy every overridden layer's values to its source in the main (geometry, swaps, property - // values and slot content stay with the instance). + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). let mut pushed = 0; for d in doc.subtree(inst) { let Some(n) = doc.node(d).cloned() else { continue }; @@ -691,7 +691,9 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let groups: BTreeSet = n .overrides .iter() - .filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")) + .filter(|g| { + !matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name") + }) .cloned() .collect(); for g in &groups { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 61b0f8b4..9b471290 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc()?; let pn = doc.get(parent)?; if !pn.kind.is_container() { @@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } } if let NodeKind::Text(td) = &mut kind { - td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default(); + td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default()); if w > 0.0 { td.auto_resize = TextAutoResize::Height; } @@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { node.width = tw; node.height = th; } - let index = num(p, "index").map(|i| i.max(0.0) as usize); + let index = crate::params::opt_index(cmd, p, "index")?; doc.insert(node, parent, index)?; if let Some(Value::Object(props)) = get(p, "props") { super::props::apply(cmd, doc, nid, props)?; @@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { _ => ImageScaleMode::Fill, }; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8e89d825..804c7819 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,6 +25,13 @@ pub fn specs() -> Vec { spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + true, + paste_design_layers + ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), @@ -496,9 +503,27 @@ fn bump_name(n: &str) -> String { } /// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let doc = &ds.doc; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); @@ -521,28 +546,73 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); - let uid = ds.uid; - let source = Some(ds.doc.clone()); - let source_path = ds.path.clone(); - s.clipboard.source = source; - s.clipboard.source_path = source_path; - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.source_doc = uid; - Ok(()) + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + let props = s.clipboard.props.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.copy", &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). @@ -1003,7 +1073,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if find.is_empty() { return Err(bad("edit.findReplace", "`find` is empty")); } - let replace = string(p, "replace"); + // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). + let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); let names = boolean(p, "layerNames").unwrap_or(false); let page = s.page()?; let doc = s.doc_mut()?; @@ -1237,7 +1308,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = num(p, "index").map(|x| x.max(0.0) as usize); + let idx = opt_index(cmd, p, "index")?; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5043ea40..6ba1ba6c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -41,8 +41,20 @@ pub fn specs() -> Vec { false, open_recent ), - spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), - spec!("file.saveAs", "Save as", "{path}", false, save_as), + spec!( + "file.save", + "Save", + "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save + ), + spec!( + "file.saveAs", + "Save as", + "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save_as + ), spec!( "file.export", "Export", @@ -74,7 +86,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -461,6 +473,11 @@ fn file_stem(path: &str) -> String { std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() } +/// The counts the import report shows for a freshly loaded document. +fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + /// Load a document from any supported file (REST API file JSON images come from an `images` /// folder next to the file). pub fn load(path: &str) -> Result<(Document, Vec)> { @@ -506,10 +523,8 @@ fn load_with( Ok((doc.into(), false)) } _ => { - if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; @@ -525,10 +540,20 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { crate::style_vars::after_load(doc); + super::styles::apply_paint_bindings_on_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); @@ -653,8 +678,13 @@ fn open_recent(s: &mut Session, p: &Value) -> Result { } fn open(s: &mut Session, p: &Value) -> Result { - let path = req_string("file.open", p, "path")?; let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; // Already open: switch to it. if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; @@ -672,9 +702,26 @@ fn open(s: &mut Session, p: &Value) -> Result { more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), }); } + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -775,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": profile.id(), "changed": changed})) } -/// Save the file of the active tab (the main file for a branch tab, with its branches). +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. fn save_to(s: &mut Session, path: String) -> Result { + if ext(&path) == "fig" { + return export_fig(s, &json!({"path": path})); + } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let bytes = file_bytes(ds)?; @@ -795,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result { return save_to(s, path); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } Some(path) => save_to(s, path), None => { s.request_ui("dialog.saveAs", json!({})); @@ -824,12 +888,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: } /// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); ids.iter() .filter_map(|i| { - let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) } + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } }) .reduce(|a, b| a.union(b)) } @@ -1206,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result { }; let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages}); + let mut out = json!({"pages": pages, "summary": summary}); if rest { out["warnings"] = json!(other.issues); } @@ -1243,6 +1317,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { map.insert(id, doc.alloc_id()); } } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } let mut new_pages = Vec::new(); for pg in &other.pages { let Some(pn) = other.node(*pg) else { continue }; @@ -1260,6 +1347,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } new_pages.push(np); } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } for new in map.values() { let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { @@ -1271,7 +1365,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } fn close(s: &mut Session, p: &Value) -> Result { - let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active); + let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; // A branch tab's edits are in its main file already (which then has unsaved changes). if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) { @@ -1325,6 +1419,70 @@ mod rest_import_tests { doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() } + /// What a .fig import with a cached library component leaves: the main in the hidden store, a + /// visible instance of it and a layer linked to the main's layer. + fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = + doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc + } + + /// Pasting layers that use a cached main into another document leaves the main behind, as it + /// does for other mains that are not published: no link points at a layer of the target. + #[test] + fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); + } + + #[test] + fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); + } + #[test] fn rest_json_loads_with_linked_instances() { let bytes = serde_json::to_vec(&kit()).unwrap(); @@ -1342,6 +1500,25 @@ mod rest_import_tests { assert!(thumbnail(&doc).is_some()); } + #[test] + fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 0064ffe0..08e65b0b 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,5 +1,6 @@ //! Pages. +use figcraft_doc::proto::Flow; use figcraft_doc::*; use serde_json::{Value, json}; @@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { Ok(()) } +/// The page a command targets: the explicit `id` (which must be a page) or the open page. +fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result { + let Some(target) = id(p, "id") else { return s.page() }; + if !s.doc()?.pages.contains(&target) { + return Err(bad(cmd, format!("{target} is not a page"))); + } + Ok(target) +} + pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { @@ -71,13 +81,13 @@ pub fn specs() -> Vec { }), spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| { let name = req_string("page.rename", p, "name")?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.rename", p)?; s.doc_mut()?.get_mut(id)?.name = name; settle(s, "page.rename")?; ok() }), spec!("page.delete", "Delete page", "{id?}", true, |s, p| { - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.delete", p)?; s.doc_mut()?.remove(id)?; settle(s, "page.delete")?; ok() @@ -87,13 +97,25 @@ pub fn specs() -> Vec { let doc = s.doc_mut()?; let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); - if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone() - && let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind + // One copy with one id map, so navigation between the copied frames points at the copies; + // then each layer and its copy (also those in sections), so flows can follow their start frames. + let children = doc.get(src)?.children.clone(); + let copied = doc.duplicate_all_into(&children, np)?; + let mut copies = Vec::new(); + for (c, copy) in children.iter().zip(&copied) { + copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy))); + } + // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. + if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() + && let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind { *b = background; - } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; + *f = flows + .into_iter() + .filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl })) + .collect(); + *pr = prototype; + *g = guides; } // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { @@ -117,7 +139,7 @@ pub fn specs() -> Vec { }), spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| { let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.setBackground", p)?; if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind { *background = c; } @@ -163,8 +185,11 @@ pub fn specs() -> Vec { } guides.remove(k); let ds = s.ds_mut()?; - if ds.guide == Some((owner, k)) { - ds.guide = None; + match ds.guide { + Some((o, i)) if o == owner && i == k => ds.guide = None, + // A later guide of the same owner moves up one place; keep the selection on it. + Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)), + _ => {} } ok() }), diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index e9578b62..ca62962d 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; +/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same). +pub(crate) fn normalize_line_endings(s: &str) -> String { + s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n") +} + /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { match v { @@ -649,7 +654,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map &'static str { "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" | "hangingPunctuation" | "hangingList" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", - "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", + "width" | "height" | "w" | "h" => "size", + "sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing", "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding", "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment", diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..500fa021 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -26,10 +26,17 @@ pub fn specs() -> Vec { spec!( "object.convertToFrame", "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)", true, |s, p| convert(s, p, "object.convertToFrame", true) ), + spec!( + "object.convertToSection", + "Convert to section", + "{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)", + true, + convert_to_section + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res let n = doc.get(i)?; let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); match (&n.kind, to_frame) { + // A frame can't hold sections. + (NodeKind::Section { .. }, true) + if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) => + { + return refuse("holds sections, which can't go inside a frame: convert those first"); + } + (NodeKind::Section { .. }, true) => todo.push(i), (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { @@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res } for i in &todo { if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. + // The box as it is and nothing clipped, so the look stays: a group has nothing painted, a + // section keeps its fill. let n = doc.get_mut(*i)?; + if matches!(n.kind, NodeKind::Group) { + n.fills.clear(); + n.strokes.clear(); + } n.kind = NodeKind::Frame; n.clip = false; - n.fills.clear(); - n.strokes.clear(); continue; } let n = doc.get_mut(*i)?; @@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res Ok(json!({"ids": todo})) } +/// Frames on the page or in a section become sections in place (sections only sit there). +fn convert_to_section(s: &mut Session, p: &Value) -> Result { + let cmd = "object.convertToSection"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })); + match &n.kind { + NodeKind::Section { .. } => {} + NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"), + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"), + NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"), + NodeKind::Frame => todo.push(i), + _ => return refuse("is not a frame"), + } + } + for i in &todo { + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Section { contents_hidden: false }; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.grids.clear(); + n.guides.clear(); + n.styles.grid = None; + n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; @@ -309,33 +363,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; let index = doc.index_in_parent(top).map(|i| i + 1); let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; + let mut net = VectorNetwork::default(); for i in &ids { let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; + // Vectors keep their own network (open paths and unfilled loops have no fill outline); + // booleans flatten to their result; strokes don't get outlined. + let mut part = match &n.kind { + NodeKind::Vector { network } => network.clone(), + _ => { + let Some((op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => { + figcraft_render::subtree_outline(doc, n, 0) + } + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + let mut part = VectorNetwork::from_path(&op); + if rule == figcraft_render::FillRule::EvenOdd { + for r in &mut part.regions { + r.winding = WindingRule::EvenOdd; + } + } + part + } }; - even |= rule == figcraft_render::FillRule::EvenOdd; // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); + let all: Vec = (0..part.vertices.len()).collect(); + crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i)); + let (nv, ns) = (net.vertices.len(), net.segments.len()); + net.vertices.extend(part.vertices); + net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s })); + net.regions.extend( + part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }), + ); } - if path.elements().is_empty() { + let Some(bb) = net.bounds() else { return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } + }; let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } + net.translate(-bb.x0, -bb.y0); let vid = doc.alloc_id(); let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); @@ -556,6 +623,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.offset", s, p)? { let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); + // A vector can't hold children: leave containers (frames, groups, instances) alone. + if !n.children.is_empty() { + continue; + } let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index a5f2b78f..25e36e3a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -112,6 +112,9 @@ pub fn specs() -> Vec { spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { let name = req_string("variable.createCollection", p, "name")?; let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + } let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), @@ -910,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) { /// [`apply_bindings`] for the layers `only` (every bound layer when `None`). pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet>) { + apply_bindings_matching(doc, only, |_| true); +} + +/// Settle imported paint colours before the document is first shown. Other bindings can affect +/// layout or component structure, which must be resolved through the normal edit pipeline. +pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { + let pending = doc.take_dirty(); + apply_bindings_matching(doc, None, |field| { + super::lists::list_binding(field).is_some_and(|(list, _, prop)| { + matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes) + && (prop == "color" || super::lists::stop_binding(prop).is_some()) + }) + }); + doc.take_dirty(); + doc.mark_dirty(pending); +} + +fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { return; @@ -929,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr let mut grid_sets: Vec<(usize, String, f64)> = Vec::new(); let mut prop_sets: Vec<(String, VariableValue)> = Vec::new(); for (field, var) in binds { + if !include(&field) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 14ea1762..3b861895 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -332,7 +332,7 @@ pub fn specs() -> Vec { spec!( "text.preview", "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index ae627abe..3e6dc69d 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } None => None, }; - let scopes = match get(p, "scopes") { + let scopes = match p.get("scopes") { None => None, Some(Value::Null) => Some(None), Some(Value::Array(a)) => { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0f09a6db..de69e690 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result { let pages = &ds.doc.pages; let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64; let n = pages.len() as i64; - let next = (i + dir).clamp(0, n - 1) as usize; - let Some(pg) = pages.get(next).copied() else { return ok() }; + // Dividers never open: step over them, and stay put when only dividers lie ahead. + let mut next = i + dir; + let pg = loop { + let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied()); + match page { + Some(p) if !ds.doc.is_divider(p) => break p, + Some(_) if dir != 0 && next >= 0 && next < n => next += dir, + _ => break ds.page, + } + }; let ds = s.ds_mut()?; ds.page = pg; ds.selection.clear(); diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 513bf509..7d35229f 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String { } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end -/// in "px" are left alone. +/// in "px", markup lines (layer text), string literals and comments are left alone. pub fn px_to_rem(code: &str, root: f64) -> String { + let mut out = String::with_capacity(code.len()); + let mut in_comment = false; + for line in code.split_inclusive('\n') { + if !in_comment && line.trim_start().starts_with('<') { + out.push_str(line); + continue; + } + // Lengths outside strings and comments convert; `plain` is where that stretch starts. + let mut plain = 0; + let mut chars = line.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if in_comment { + if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') { + chars.next(); + out.push_str(line.get(plain..i + 2).unwrap_or("")); + plain = i + 2; + in_comment = false; + } + } else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') { + chars.next(); + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + plain = i; + in_comment = true; + } else if c == '"' { + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + let mut end = line.len(); + while let Some((j, s)) = chars.next() { + if s == '\\' { + chars.next(); + } else if s == '"' { + end = j + 1; + break; + } + } + out.push_str(line.get(i..end).unwrap_or("")); + plain = end; + } + } + let rest = line.get(plain..).unwrap_or(""); + if in_comment { + out.push_str(rest); + } else { + out.push_str(&lengths_to_rem(rest, root)); + } + } + out +} + +/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone. +fn lengths_to_rem(code: &str, root: f64) -> String { let ident = |c: char| c.is_alphanumeric() || c == '_'; let mut out = String::with_capacity(code.len()); let mut rest = code; diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index c810cf1f..1416b15f 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { } } -/// `data-*` attribute names for variant parameters. -fn data_attr(p: &Param) -> String { +/// `data-*` attribute names for variant parameters, unique among them (in order). +fn data_attrs(variants: &[&Param]) -> Vec { let mut used = Vec::new(); - format!("data-{}", class_name(&p.prop, &mut used)) + variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect() } /// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom @@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); let root_class = class_name(&root_name, &mut Vec::new()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); + let attr_names = data_attrs(&variants); let pass = |root: NodeId| { let vctx = Ctx { doc, root, comp: Some(c), base: 2 }; let mut m = Markup::new(&vctx, lang); @@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String m.root_class = Some(root_class.clone()); m.root_attrs = variants .iter() - .map(|p| match lang { - CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)), - _ => format!(" {}={{{}}}", data_attr(p), p.ident), + .zip(&attr_names) + .map(|(p, attr)| match lang { + CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)), + _ => format!(" {attr}={{{}}}", p.ident), }) .collect(); m.node(c.comp, None, 2, ""); @@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let pairs = variant_props(&vn.name); let attrs: String = variants .iter() - .filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], "")))) + .zip(&attr_names) + .filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], "")))) .collect(); for (path, _, decls) in pass(v) { let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue }; diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index c92815a7..4cd3d776 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() { let react = code(&mut s, c, "react"); assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}"); } + +#[test] +fn case_colliding_variant_properties_get_distinct_data_attributes() { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() { + let name = format!("Size={first}, size={second}"); + let f = idv(&ex( + &mut s, + "frame.create", + json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}), + )); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants}))); + ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}})); + for lang in ["react", "leptos"] { + let v = component_code(&mut s, set, lang); + let def = v["definition"].as_str().unwrap_or_default(); + let root = def.lines().find(|l| l.trim_start().starts_with("
= + root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect(); + assert_eq!(attrs.len(), 2, "{lang}: {root}"); + assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}"); + let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]); + assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}"); + assert!(def.contains("#FF0000"), "{lang}:\n{def}"); + } +} diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index c6bf0417..ff582b8a 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -11,12 +11,12 @@ //! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, -//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested -//! instance) and `slot` (a slot holding the instance's own content). An instance's own export -//! settings and interactions are its own, like its place. `size` and `position` overrides of -//! layers inside an instance are still read and kept, but new ones are refused -//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the -//! main; the instance itself resizes. +//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers +//! inside an instance, `component` (a swapped nested instance), `props` (property values of a +//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own +//! export settings, interactions and resizing are its own, like its place. `position` +//! overrides of layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their place and constraints from the main. //! //! Switching variants keeps an override where the layer's name path matches and the two variants //! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps @@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "exports", "interactions", "size", + "resizing", "position", "component", "props", @@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { "size" => { to.width = from.width; to.height = from.height; - to.child_layout = from.child_layout.clone(); if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { b.auto_resize = a.auto_resize; } } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } "position" => to.transform = from.transform, "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { @@ -472,11 +476,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { } } "characters" => { - if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str()) + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) && t.characters != sv { let len = t.char_count(); - t.replace_range(0, len, sv); + t.replace_range(0, len, &sv); } } "mainComponent" => { @@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT for s in &src { let Some(nid) = map.get(s).copied() else { continue }; let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } if !o.overrides.contains("size") && !o.overrides.contains("position") { continue; } @@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { out.push("size"); } + if resizing(a) != resizing(b) { + out.push("resizing"); + } if a.transform != b.transform { out.push("position"); } @@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { out } +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + /// Whether two texts differ in anything but their characters (the `textStyle` group). fn text_style_differs(x: &TextData, y: &TextData) -> bool { x.style != y.style @@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, export settings and interactions are its own, not overrides. + // An instance's own place, resizing, export settings and interactions are its own, not + // overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { let Some(p) = doc.node(parent) else { return false }; let constrains = p.layout.is_none() || absolute; - let resized = p.overrides.contains("size") + let resized = sized_own(p) || match &p.kind { NodeKind::Instance { component, .. } => { doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) @@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { chain.windows(2).all(|w| { let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; let constrains = a.layout.is_none() || below.child_layout.absolute; - if a.overrides.contains("size") && constrains { + if sized_own(a) && constrains { return false; } match &a.kind { @@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { }) } +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + /// Rebuild every instance of `comps` (and of the components that hold those instances, and so on /// outwards), each outermost instance once, inner components first. fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { @@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St /// What about layer `id` an edit from `before` to `after` changed that only its main can: the /// auto layout of an instance or a layer in one but its padding, gap and alignment, or the -/// position (rotation too), size, constraints or resizing of a layer inside an instance (the -/// instance itself resizes, and its layers follow; text whose box follows its content still -/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a -/// library update). +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { let (n, b) = (after.node(id)?, before.node(id)?); if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { @@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti l }) }; - let size = |x: &Node| (x.width, x.height); - let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; if mode(n) != mode(b) && mode(ma) == mode(mb) { return Some("The auto layout direction"); } @@ -1698,18 +1730,174 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { return Some("A layer's position"); } - if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") { - return Some("A layer's size"); - } if n.constraints != b.constraints && ma.constraints == mb.constraints { return Some("A layer's constraints"); } - if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout { - return Some("A layer's resizing"); + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); } None } +/// Bounded source and parent lookups while a rebase reads an unchanged document. +struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1722,22 +1910,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() }; let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); for (id, path) in &mine { let Some(n) = doc.node(*id) else { continue }; - // Follow the source chain into the main first (exact), then fall back to the name path. - let mut main = None; - let mut cur = n.source; - for _ in 0..MAX_DEPTH { - let Some(c) = cur else { break }; - if c != component && doc.contains(c) && doc.is_ancestor(component, c) { - main = Some(c); - break; - } - cur = doc.node(c).and_then(|x| x.source); + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; } - let main = main.or_else(|| theirs.get(path).copied()); + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = main.and_then(|m| doc.node(m)) { + if let Some(m) = source.and_then(|source| doc.node(source)) { let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); @@ -1745,36 +1939,53 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { overrides.insert("component".into()); } } - updates.push((*id, main, overrides)); + updates.push((*id, source, overrides)); } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || g != "resizing") + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); for (id, main, overrides) in updates { if let Some(n) = doc.node_mut(id) { n.source = main; n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; } } - let root_groups = match (doc.node(component), doc.node(inst)) { - (Some(m), Some(n)) => { - let from_variables = variable_groups(n, m); - let mut groups: Vec = value_groups(m, n) - .into_iter() - .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .collect(); - // A layer name of its own (a nested instance renamed in its old main) stays. - if n.name != default_instance_name(doc, component) { - groups.push("name".into()); - } - groups - } - _ => Vec::new(), - }; if let Some(n) = doc.node_mut(inst) { - n.source = Some(component); - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); + n.source = root_base; + // A top-level instance's resizing is its own (a nested one's overrides its main layer's). + n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); n.overrides.extend(root_groups); } } +#[cfg(test)] +#[path = "components_rebase_source_tests.rs"] +mod rebase_source_tests; + /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). pub fn default_variant(doc: &Document, set: NodeId) -> Option { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 325ccdee..15f660e1 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() { ("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"), ("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"), ("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"), - ( - "resize a layer", - "node.set", - json!({"id": "$", "props": {"width": 50}}), - Layer::Bg, - "not among the overridable properties; the instance resizes", - ), - ("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""), ( "change constraints", "node.set", @@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() { Layer::Bg, "\"any constraints applied to the layers\"", ), - ( - "make a layer fill its auto layout", - "node.set", - json!({"id": "$", "props": {"sizingH": "fill"}}), - Layer::Chip, - "resizing comes with the layer's size", - ), ( "take a layer out of auto layout", "node.set", diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs new file mode 100644 index 00000000..b272b82f --- /dev/null +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -0,0 +1,244 @@ +//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains. + +use super::*; + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap() +} + +fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId { + add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name) +} + +fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId { + let mut n = doc.node(source).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.parent = None; + n.source = Some(source); + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId { + let mut n = doc.node(main).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + n.name = name.into(); + n.parent = None; + n.source = None; + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +struct Scene { + doc: Document, + inner: NodeId, + base: NodeId, + slot: NodeId, + context: NodeId, + root: NodeId, + nested: NodeId, + child: NodeId, +} + +fn scene() -> Scene { + let mut doc = Document::new("Source context"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let outer = component(&mut doc, page, "Outer"); + let slot = instance(&mut doc, inner, outer, "Nested"); + doc.node_mut(slot).unwrap().source = Some(inner); + let context = doc.node(slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + doc.node_mut(context).unwrap().opacity = 0.3; + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + doc.node_mut(child).unwrap().opacity = 0.2; + Scene { doc, inner, base, slot, context, root, nested, child } +} + +fn both(s: &mut Scene) { + rebase_instance(&mut s.doc, s.nested); + rebase_instance(&mut s.doc, s.root); +} + +#[test] +fn rebase_default_only_sources_promote_to_the_actual_outer_context() { + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().source = None; + s.doc.node_mut(s.child).unwrap().source = None; + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); +} + +#[test] +fn rebase_rejects_a_context_source_from_another_nested_slot() { + let mut s = scene(); + let outer = s.doc.node(s.slot).unwrap().parent.unwrap(); + let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot"); + s.doc.node_mut(sibling).unwrap().source = Some(s.inner); + let sibling_child = s.doc.node(sibling).unwrap().children[0]; + mirror(&mut s.doc, sibling, s.root); + s.doc.node_mut(s.child).unwrap().source = Some(sibling_child); + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0); + assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0); +} + +#[test] +fn rebase_does_not_mark_inherited_context_values_as_local_overrides() { + let mut s = scene(); + s.doc.node_mut(s.child).unwrap().opacity = 0.3; + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity")); +} + +#[test] +fn rebase_preserves_multilevel_context_without_shortening_source_chains() { + let mut doc = Document::new("Nested source contexts"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let wrapper = component(&mut doc, page, "Wrapper"); + let default_slot = instance(&mut doc, inner, wrapper, "Nested"); + doc.node_mut(default_slot).unwrap().source = Some(inner); + let outer = component(&mut doc, page, "Outer"); + let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot"); + doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper); + let source_slot = doc.node(wrapper_slot).unwrap().children[0]; + let context = doc.node(source_slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + let root = instance(&mut doc, outer, page, "Outer copy"); + let wrapper_copy = doc.node(root).unwrap().children[0]; + let nested = doc.node(wrapper_copy).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(nested).unwrap().name = "Renamed nested".into(); + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + for id in [nested, wrapper_copy, root] { + rebase_instance(&mut doc, id); + assert_eq!(doc.node(nested).unwrap().source, Some(source_slot)); + assert_eq!(doc.node(child).unwrap().source, Some(context)); + } + assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0])); + assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_preserves_placed_frame_to_instance_and_current_main_children() { + let mut doc = Document::new("Typed nested source"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let body = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + let outer = component(&mut doc, page, "Outer"); + let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot"); + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() }; + let child = mirror(&mut doc, body, nested); + rebase_instance(&mut doc, nested); + rebase_instance(&mut doc, root); + assert_eq!(doc.node(nested).unwrap().source, Some(slot)); + assert_eq!(doc.node(child).unwrap().source, Some(body)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner)); +} + +#[test] +fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.remove(s.child).unwrap(); + s.child = mirror(&mut s.doc, body, s.nested); + s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into(); + rebase_instance(&mut s.doc, s.nested); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + rebase_instance(&mut s.doc, s.root); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(body)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0); +} + +#[test] +fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + let child = s.doc.node(detached).unwrap().children[0]; + // The old source references still exist, but their Outer context is absent here. + s.doc.node_mut(detached).unwrap().source = Some(s.slot); + s.doc.node_mut(child).unwrap().source = Some(s.context); + s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0)); + rebase_instance(&mut s.doc, detached); + assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner)); + assert_eq!(s.doc.node(child).unwrap().source, Some(s.base)); + assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0); + assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let foreign = component(&mut s.doc, page, "Foreign"); + let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.child).unwrap().source = Some(foreign_body); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.node_mut(s.child).unwrap().source = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap"); + assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body)); +} + +#[test] +fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() { + let mut s = scene(); + s.doc.node_mut(s.base).unwrap().source = Some(s.context); + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, None); + let mut s = scene(); + s.doc.node_mut(s.context).unwrap().parent = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().parent = Some(s.child); + s.doc.node_mut(s.child).unwrap().parent = Some(s.nested); + let sources = RebaseSources::new(&s.doc); + assert!(sources.owners(s.child).is_none()); + let mut s = scene(); + s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() }; + rebase_instance(&mut s.doc, s.root); + assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root)); +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index eb21b20e..209560cd 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -67,8 +67,6 @@ fn overrides_survive_main_edits() { ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}})); ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"})); ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}})); - // A layer inside an instance keeps its main's size (the instance itself resizes). - assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err()); let ov = node(&s, ibg).overrides; for g in ["fills", "strokes", "effects", "name"] { assert!(ov.contains(g), "{g} in {ov:?}"); diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 7c25a620..620388d2 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -1,5 +1,5 @@ -//! Frames and groups converted into each other in place: the layers stay where they are on the -//! page, what only a frame has goes with it, what can't convert is refused, one undo step. +//! Frames, groups and sections converted into each other in place: the layers stay where they are on +//! the page, what only a frame has goes with it, what can't convert is refused, one undo step. use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() { #[test] fn junk_parameters_are_refused_not_fatal() { let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; - for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] { for j in &junk { let mut s = Session::new(); let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); @@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() { } } } + +#[test] +fn a_frame_on_the_page_becomes_a_section_and_back() { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}}))); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0)); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)]; + assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false })); + assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill"); + assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section"); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves"); + // And back: a frame that keeps the fill and doesn't clip, so the look stays. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before); + // Each conversion is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Section { .. })); + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Frame)); + assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back"); +} + +#[test] +fn only_frames_on_the_page_or_in_a_section_become_sections() { + let mut s = Session::new(); + let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300}))); + let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100}))); + let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string(); + assert!(err.contains("inside a frame"), "{err}"); + let r = rect(&mut s, outer, 150.0, 150.0); + assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame"); + // A frame inside a section converts; a section holding a section can't become a frame. + let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400}))); + let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100}))); + ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]})); + assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. })); + let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string(); + assert!(err.contains("holds sections"), "{err}"); + assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); +} diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 0975f3fa..7a9e0621 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { let before = s.doc().unwrap().world_bounds(inner).unwrap(); assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0)); ex(&mut s, "select.set", json!({"ids": [inner]})); - // Resizing it by its right edge: only the main component can. - assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow"); + // Resizing it by its right edge: a size of its own (an override). + assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow"); s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed); + assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed); s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap(); - assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone"); - // Moving it: refused too. + let before = s.doc().unwrap().world_bounds(inner).unwrap(); + assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays"); + // Moving it: only the main component can. s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap(); s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap(); @@ -219,6 +220,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone"); } +#[test] +fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + // A real click: the mouse moves a pixel or two between press and release. + s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o); + assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out"); + assert!(s.tool.overlay.pen_handles.is_empty()); + // Past the slop it is a drag: the handle follows the pointer from where it was pressed. + s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y)); + assert_eq!(out, Some((4.0, 0.0))); +} + #[test] fn the_pen_shows_what_a_click_would_do() { // The Pen tool drawing a new path: three points, then over each of them. diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs new file mode 100644 index 00000000..89de80ef --- /dev/null +++ b/crates/engine/src/design_paste_tests.rs @@ -0,0 +1,108 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + +use base64::Engine as _; +use serde_json::json; + +use super::*; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() +} + +fn copied_file() -> Vec { + let mut s = Session::new(); + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap(); + s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + B64.decode(r["data"].as_str().unwrap()).unwrap() +} + +fn clipboard_html(buffer: &[u8]) -> String { + let mut wrapped = B64.encode(buffer); + wrapped.insert(8, '\n'); + format!(r#""#) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); + assert_eq!(s.selection(), ids); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_copied_layers() { + let mut s = Session::new(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +fn pastes_into_the_selected_frame() { + let mut s = Session::new(); + let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); + s.execute("select.set", &json!({"ids": [host]})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let first = params::id_of(&r["ids"][0]).unwrap(); + assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert_eq!(s.doc().unwrap().nodes().count(), before); + assert!(s.clipboard.nodes.is_empty()); +} diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 5583efe9..1607ce56 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() { click(&mut s, 350.0, 100.0); assert_eq!(s.selection(), vec![b], "everything is hit again"); } + +#[test] +fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() { + let mut s = Session::new(); + let screen = frame(&mut s, 0.0, "Screen"); + let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"}))); + // A status a nested frame already stores (an older file, or set through node.set) is ignored. + ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}})); + assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([])); + assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let ids = |v: serde_json::Value| -> Vec { + v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default() + }; + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]); + ex(&mut s, "dev.focus", json!({"id": screen})); + for direction in ["next", "previous"] { + let v = ex(&mut s, "dev.focus", json!({"direction": direction})); + assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame"); + } + // A frame directly inside a section still counts. + let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300}))); + let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"}))); + ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}})); + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index e94e88b5..fe400989 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool { } /// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page), -/// in page and layer order (bounded). +/// in page and layer order (bounded). Only layers that can carry a status are listed. pub fn dev_items(doc: &Document, status: Option, page: Option) -> Vec { let mut out = Vec::new(); let mut visited = 0usize; @@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option, page: Option return out; } let Some(n) = doc.node(id) else { continue }; - if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) { + if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) { out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); } stack.extend(n.children.iter().rev().copied()); diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index f478735e..d5e8b3cf 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect { /// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each /// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children -/// wrap in flow order, so a new row starts where a child sits before the one before. +/// wrap in flow order, so a new row starts where a child sits before the one before, or where it +/// does not follow the one before along the flow but sits past it across (one child per line). pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec> { let mut rows: Vec> = Vec::new(); for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) { match rows.last_mut() { - Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)), + Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => { + row.push((i, b)) + } _ => rows.push(vec![(i, b)]), } } diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index b65fbb64..4c62179a 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() { cmd_d(&mut s); assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); } + +/// Drag `from` by `by` with no modifier: moving the selection, not copying it. +fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { + let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0); + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); +} + +/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests +/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, +/// and the step is measured from where the copy ended up, whichever way it got there. +#[test] +fn duplicate_repeats_the_step_set_by_dragging_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + + drag_move(&mut s, (50.0, 50.0), (150.0, 20.0)); + assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one"); + assert_eq!(s.selection(), vec![c1], "and left it selected"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); + + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +/// And a step set with the arrow keys, which is how a small offset is usually made exact. +#[test] +fn duplicate_repeats_a_nudged_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0})); + assert_eq!(at(&s, c1), (40.0, 0.0)); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step"); +} diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs index d6241448..f0ea0886 100644 --- a/crates/engine/src/fig_import_tests.rs +++ b/crates/engine/src/fig_import_tests.rs @@ -50,3 +50,43 @@ fn local_samples_match_their_exports() { } } } + +/// Layers of an instance copy keep their link to the layer they stand for when the file is +/// opened, also after they were renamed in the copy (a name is no identity), and the override +/// that a rename is stays an override. +#[test] +fn opened_fig_links_instance_layers_by_identity_not_by_name() { + use base64::Engine as _; + let mut s = Session::new(); + let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap(); + let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"})); + let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}})); + let inner = make(&mut s, "component.create", json!({"ids": [body]})); + let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"})); + let outer = make(&mut s, "component.create", json!({"ids": [frame]})); + let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer})); + s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap(); + let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200})); + let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0]; + let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0]; + s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap(); + s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap(); + let _ = label; + + let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id())); + std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap(); + let mut opened = Session::new(); + let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + result.unwrap(); + let doc = opened.doc().unwrap(); + let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(copy.children[0]).unwrap(); + let label = doc.node(slot.children[0]).unwrap(); + assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label")); + assert_eq!(slot.source, Some(outer.children[0])); + assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0])); + assert!(slot.overrides.contains("name") && label.overrides.contains("name")); +} diff --git a/crates/engine/src/finite_geometry_tests.rs b/crates/engine/src/finite_geometry_tests.rs new file mode 100644 index 00000000..416b250c --- /dev/null +++ b/crates/engine/src/finite_geometry_tests.rs @@ -0,0 +1,40 @@ +//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be +//! reopened (JSON writes NaN and infinity as null). + +use serde_json::json; + +use crate::Session; + +fn rect(s: &mut Session) -> crate::NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap(); + crate::params::id_of(&r["id"]).unwrap() +} + +fn assert_roundtrips(s: &Session) { + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens"); +} + +#[test] +fn huge_moves_are_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok(); + let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308})); + assert!(second.is_err(), "a move past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform); + assert_roundtrips(&s); +} + +#[test] +fn huge_scale_is_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok(); + let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})); + assert!(second.is_err(), "a size past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height); + assert_roundtrips(&s); +} diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index 95b8f40d..b7fbb9ac 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() { let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap(); assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]); } + +fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String { + let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap(); + out["code"].as_str().unwrap().to_string() +} + +#[test] +fn rem_preserves_text_in_web_markup() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap(); + let mut changed = Vec::new(); + for language in ["html", "react", "leptos"] { + let px = web_code(&mut s, frame, language, "px"); + let rem = web_code(&mut s, frame, language, "rem"); + assert!(px.contains("Tile 24px"), "{language}: px control: {px}"); + assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}"); + assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}"); + if !rem.contains("Tile 24px") { + changed.push(language); + } + } + assert!(changed.is_empty(), "text rewritten in {changed:?}"); +} + +#[test] +fn rem_preserves_text_property_default() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap()); + let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap()); + let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap()); + s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap(); + for unit in ["px", "rem"] { + let code = web_code(&mut s, component, "react", unit); + assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}"); + if unit == "rem" { + assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}"); + } + } +} diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 00000000..7efabb48 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs new file mode 100644 index 00000000..c5207b44 --- /dev/null +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -0,0 +1,161 @@ +//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than +//! quietly meaning "none". +//! +//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id — +//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the +//! layer landed on the page while the call reported success. The same shape sat on `image.place` +//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets +//! layers (an entry that wasn't an id was dropped and the rest ran). + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// The page's children, by name (bottom first). +fn top(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect() +} + +/// The message of a failure, or `None` when the call went through. +fn failed(r: std::result::Result) -> Option { + match r { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +fn rect(parent: Value) -> Value { + json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10}) +} + +fn card(s: &mut Session) -> NodeId { + let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + id_of(&v["id"]).unwrap() +} + +#[test] +fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] { + let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused")); + assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}"); + } + assert_eq!(top(&s), ["Card"], "nothing landed on the page"); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame"); +} + +#[test] +fn leaving_the_parent_out_or_null_still_means_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [None, Some(json!(null))] { + let mut p = rect(json!(0)); + match parent { + None => _ = p.as_object_mut().unwrap().remove("parent"), + Some(v) => p["parent"] = v, + } + s.execute("node.create", &p).unwrap(); + } + assert_eq!(top(&s), ["Card", "child", "child"]); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty()); +} + +#[test] +fn a_parent_id_that_names_nothing_is_still_reported() { + let mut s = Session::new(); + card(&mut s); + let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail")); + assert!(msg.contains("no such node 99999"), "{msg}"); +} + +#[test] +fn an_index_that_is_not_a_whole_non_negative_number_is_refused() { + let mut s = Session::new(); + let card = card(&mut s); + // (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every + // other number, so it is deliberately not in this list.) + for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] { + let mut p = rect(json!(card)); + p["index"] = index.clone(); + let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused")); + assert!(msg.contains("`index`"), "{index}: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted"); +} + +#[test] +fn a_whole_index_still_inserts_where_it_says() { + let mut s = Session::new(); + let card = card(&mut s); + for name in ["a", "b"] { + s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10})) + .unwrap(); + let doc = s.doc().unwrap(); + let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect(); + assert_eq!(names, ["a", "mid", "b"]); +} + +#[test] +fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() { + let mut s = Session::new(); + let card = card(&mut s); + // The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame. + for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] { + let msg = + failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused")); + assert!(msg.contains("`ids`"), "{ids}: {msg}"); + } + let doc = s.doc().unwrap(); + assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed"); +} + +#[test] +fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() { + let mut s = Session::new(); + let card = card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail")); + assert!(msg.contains("`ids` must be an array"), "{msg}"); + // An explicit null says the same as leaving it out: the selection. + s.execute("select.set", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected"); +} + +#[test] +fn a_single_id_that_is_not_an_id_is_refused() { + let mut s = Session::new(); + card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail")); + assert!(msg.contains("`id` must be a node id"), "{msg}"); +} + +#[test] +fn an_image_and_an_instance_parent_are_checked_the_same_way() { + let mut s = Session::new(); + card(&mut s); + let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}}))); + assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place"); + let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); + assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); +} + +#[test] +fn a_huge_bad_value_is_cut_short_in_the_error() { + let mut s = Session::new(); + card(&mut s); + let junk = "é".repeat(10_000); + let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail")); + assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}"); + assert!(msg.chars().count() < 200, "{} chars", msg.chars().count()); +} diff --git a/crates/engine/src/import_journal_tests.rs b/crates/engine/src/import_journal_tests.rs new file mode 100644 index 00000000..f8fa84ef --- /dev/null +++ b/crates/engine/src/import_journal_tests.rs @@ -0,0 +1,51 @@ +//! Imported file bytes already live in the document: the command log keeps no second copy. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; + +const SVG: &str = r##""##; + +fn payload() -> String { + base64::engine::general_purpose::STANDARD.encode(SVG) +} + +fn holds_payload(journal: &[(String, Value)]) -> bool { + let payload = payload(); + journal.iter().any(|(_, params)| params.to_string().contains(&payload)) +} + +#[test] +fn byte_imports_keep_no_payload_in_the_journal() { + let mut s = Session::new(); + let before = s.doc().unwrap().node_count(); + s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap(); + assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file"); + // Not journaling is no change to the edit itself: still one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node_count(), before); +} + +#[test] +fn batch_wrappers_do_not_retain_import_payloads() { + let mut s = Session::new(); + let nested = + json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}}); + s.execute("command.batch", &json!({"commands": [nested]})).unwrap(); + assert!(!holds_payload(&s.journal)); +} + +#[test] +fn path_imports_and_ordinary_edits_are_still_journaled() { + let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kit.svg"); + std::fs::write(&path, SVG).unwrap(); + let mut s = Session::new(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.import", "shape.rectangle"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1f461c60..88bc015a 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } - // Lists: one style for the layer, or each paragraph's type and nesting level. + // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { o.insert("listStyle".into(), json!(t.list)); @@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } else { o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } } if t.list_spacing != 0.0 { o.insert("listSpacing".into(), json!(t.list_spacing)); diff --git a/crates/engine/src/instance_resizing_tests.rs b/crates/engine/src/instance_resizing_tests.rs new file mode 100644 index 00000000..43386af9 --- /dev/null +++ b/crates/engine/src/instance_resizing_tests.rs @@ -0,0 +1,190 @@ +//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or +//! keep a fixed size of its own, and that override is kept through relayout, edits of the main +//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import, +//! detach and reset. + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{copy_of, ex, healthy, idv, node}; +use figcraft_doc::*; + +/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding +/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of +/// it. Returns (component, atom, dot, instance). +fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"}))); + ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}})); + let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"}))); + ex( + s, + "node.set", + json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}), + ); + let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}}))); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100}))); + (c, atom, dot, i) +} + +fn sizing_h(s: &Session, id: NodeId) -> Sizing { + node(s, id).child_layout.sizing_h +} + +/// The layer named `name` below instance `inst`. +fn named(s: &Session, inst: NodeId, name: &str) -> NodeId { + let doc = s.doc().unwrap(); + doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap() +} + +/// The first instance in the document. +fn instance(s: &Session) -> NodeId { + s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id +} + +fn open(s: &mut Session, bytes: &[u8], name: &str) { + ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name})); +} + +#[test] +fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() { + let mut s = Session::new(); + let (c, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide"); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance"); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // Edits of the main relay the instance out: the override stays, other properties follow. + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}})); + assert_eq!(node(&s, atom).width, 120.0); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0))); + assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main"); + // The main fixing its own width is the same property: the instance keeps filling. + ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}})); + assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0)); + // A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves. + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0)); + healthy(&s); + // Back to the main's resizing: no override left. + ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width)); + assert!(!node(&s, ia).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn size_and_resizing_overrides_undo_reset_and_detach() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0)); + assert!(node(&s, ia).overrides.is_empty()); + // A fixed size of its own: kept when the main resizes the layer. + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}})); + assert!(node(&s, idot).overrides.contains("size")); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0)); + // A min width of its own. + ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}})); + assert_eq!(node(&s, ia).width, 150.0); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}})); + assert_eq!(node(&s, ia).width, 150.0); + healthy(&s); + // Reset all changes: the main's values again. + ex(&mut s, "component.resetInstance", json!({"ids": [i]})); + assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0)); + assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty()); + // Detaching keeps what the instance showed. + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "component.detach", json!({"ids": [i]})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None)); + healthy(&s); +} + +#[test] +fn layers_in_instances_resize_but_keep_their_place() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let it = copy_of(&s, i, title); + ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}})); + assert_eq!(node(&s, it).width, 150.0, "a text box resizes"); + assert!(node(&s, it).overrides.contains("size")); + // Its place, rotation and constraints are still the main's. + for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] { + assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused"); + } + // The instance's own resizing is its own, not an override. + ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}})); + assert!(!node(&s, i).overrides.contains("resizing")); + healthy(&s); +} + +#[test] +fn resizing_overrides_survive_save_and_reopen() { + let mut s = Session::new(); + let (_, atom, dot, i) = field(&mut s); + let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot)); + ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}})); + ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}})); + let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] { + let mut s = Session::new(); + open(&mut s, &bytes, name); + let i = instance(&s); + let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot")); + assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides); + assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides); + // An edit of the main relays the instance out. + let main = |s: &Session, id: NodeId| node(s, id).source.unwrap(); + let (atom, dot) = (main(&s, ia), main(&s, idot)); + ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}})); + ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}"); + healthy(&s); + } +} + +#[test] +fn imported_resizing_overrides_survive_relayout() { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}}); + let atom = |id: &str, dot_id: &str, w: f64, more: Value| { + let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20}, + "layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]}); + a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + a + }; + let field = |id: &str, ty: &str, y: f64, kids: Value| { + json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL", + "primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids}) + }; + let mut inst = + field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))])); + inst["componentId"] = json!("1:1"); + inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]); + let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]} + ]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}}); + let mut s = Session::new(); + open(&mut s, file.to_string().as_bytes(), "field.json"); + let i = instance(&s); + let ia = named(&s, i, "Atom"); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides); + // The main's first edit relays every instance out from it. + let main_atom = node(&s, ia).source.unwrap(); + ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}})); + assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0)); + healthy(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9ee6ee7f..60f99136 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -357,8 +357,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, - /// The document under a live text preview (a hovered text style or font instance, a dragged - /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + /// The document under a live text preview (a hovered font family or style, text style or + /// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved or restored (none yet): autosaves, restores and /// merges only add a checkpoint for changes since. @@ -390,7 +390,8 @@ pub struct VersionPreview { /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; -/// Commands that first end a version preview: they act on the document itself. +/// Commands that first end a version preview: they act on the document itself. Saving does so +/// only with a native destination; export copies and pending format choices keep the preview. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -401,6 +402,8 @@ impl DocState { doc.repair(); } doc.link_layer_text_styles(); + cmd::styles::apply_paint_bindings_on_load(&mut doc); + // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { @@ -606,6 +609,9 @@ pub struct Prefs { /// The widths of the main window's left and right panels (the interface keeps them within its /// limits; other windows have their own while they are open). pub panel_widths: (f32, f32), + /// The height asked for the main window's pages list, dragged at its lower edge (`None`: a + /// share of the panel; the interface keeps it within its limits). + pub pages_height: Option, /// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, @@ -652,8 +658,16 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// Recently run actions (the Actions palette's Recents), newest first, at most + /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or + /// `widget:`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_actions: Vec, } +/// Recently run actions kept for the Actions palette (newest first). +pub const MAX_RECENT_ACTIONS: usize = 5; + impl Default for Prefs { fn default() -> Self { Self { @@ -666,6 +680,7 @@ impl Default for Prefs { show_dimensions: true, theme: "system".into(), panel_widths: (240.0, 240.0), + pages_height: None, language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, @@ -681,6 +696,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + recent_actions: Vec::new(), } } } @@ -721,7 +737,8 @@ pub struct Session { /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, - /// Command log (id + params) for actions/automation. + /// Command log (id + params) for actions/automation. File imports that carry the file bytes + /// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, @@ -746,6 +763,19 @@ impl Default for Session { } } +/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. +fn command_carries_import_bytes(id: &str, params: &Value) -> bool { + match id { + "file.import" => params::get(params, "data").is_some(), + "command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| { + commands.iter().any(|c| { + c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params)) + }) + }), + _ => false, + } +} + impl Session { /// A session with one new document. pub fn new() -> Self { @@ -870,6 +900,7 @@ impl Session { // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); if ENDS_PREVIEW.contains(&id) + && (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params)) && let Ok(ds) = self.ds_mut() { ds.end_preview(); @@ -878,16 +909,21 @@ impl Session { let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) - .and_then(|v| self.check_live_access(id, &before).map(|()| v)); + .and_then(|v| self.check_live_access(id, &before).map(|()| v)) + .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } - self.journal.push((id.to_string(), params.clone())); - if self.journal.len() > 5000 { - self.journal.drain(..1000); + // Imported bytes already live in the document: a Base64 copy in the log would stay + // for the whole session. + if !command_carries_import_bytes(id, params) { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 5000 { + self.journal.drain(..1000); + } } } Err(_) => { @@ -916,6 +952,22 @@ impl Session { Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } + /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file + /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back). + fn check_finite_geometry(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite()); + let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)); + match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) { + Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))), + None => Ok(()), + } + } + /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { @@ -1135,13 +1187,22 @@ impl Session { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; - results.push(s.execute(id, &p)?); + let v = s.batch_step(&results, step, id, p)?; + results.push(v); } Ok(results) }) } + /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of + /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one + /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps + /// resolve `$step` the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; + self.execute(cmd, &p) + } + /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { @@ -1236,8 +1297,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { pub fn normalize(doc: &mut Document, before: &Document) { // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet = doc.take_dirty(); - // A variable edit changes no layer directly, but bound layers must follow it. - if touched.is_empty() && doc.variables == before.variables { + // A variable or style edit changes no layer directly, but bound and linked layers must follow it. + if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles { return; } let m = measure::TextMeasure; @@ -1500,6 +1561,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1528,6 +1591,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod finite_geometry_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; @@ -1548,18 +1613,30 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] +mod id_params_behaviour_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_journal_tests; +#[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_resizing_tests; +#[cfg(test)] mod interaction_rollback_tests; #[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_range_styles_tests; +#[cfg(test)] mod library_variables_tests; #[cfg(test)] +mod line_endings_tests; +#[cfg(test)] mod live_access_tests; #[cfg(test)] mod live_docs_tests; @@ -1588,6 +1665,14 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_clipboard_tests; +#[cfg(test)] +mod page_copy_navigation_tests; +#[cfg(test)] +mod page_target_tests; +#[cfg(test)] +mod paint_index_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; @@ -1636,6 +1721,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_format_tests; +#[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; @@ -1704,6 +1791,8 @@ mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] +mod vector_offset_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 936d798a..daa1f5bd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec { out } -/// The style ids a layer links to (fill, stroke, effect, grid, text). +/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on +/// character ranges). pub(crate) fn style_links(n: &Node) -> impl Iterator { - [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten() + let chars = + n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref()); + [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars) } /// Style ids the layers under `roots` link to. @@ -446,9 +449,27 @@ struct Stamp { #[derive(Clone, Debug)] struct Entry { stamp: Option, + /// When the file was read: a stamp only proves the file unchanged once its modification time + /// is safely older than that, since file systems keep the time in coarse ticks. + read_at: Option, result: std::result::Result, String>, } +/// How far a file's modification time must lie before its read for the stamp to be trusted. +const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2); + +impl Entry { + /// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on + /// every load until the clock passes it. + fn still_valid(&self, now: &Stamp) -> bool { + let settled = match (now.modified, self.read_at) { + (Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at), + _ => false, + }; + settled && self.stamp.as_ref() == Some(now) + } +} + /// Library files read by a session, by path (re-read when they change on disk). #[derive(Clone, Debug, Default)] pub struct Cache { @@ -478,10 +499,11 @@ impl Cache { return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024))); } if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp) - && e.stamp.as_ref() == Some(st) + && e.still_valid(st) { return e.result.clone().map_err(EngineError::Other); } + let read_at = now(); let result = match &stamp { Ok(_) => read_file(path).map(Arc::new), Err(e) => Err(e.clone()), @@ -489,7 +511,7 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() }); + self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() }); result.map_err(EngineError::Other) } @@ -503,10 +525,20 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: None, result }); + self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result }); } } +#[cfg(not(target_arch = "wasm32"))] +fn now() -> Option { + Some(std::time::SystemTime::now()) +} + +#[cfg(target_arch = "wasm32")] +fn now() -> Option { + None +} + #[cfg(not(target_arch = "wasm32"))] fn stat(path: &str) -> std::result::Result { let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; @@ -582,11 +614,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String { } /// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than -/// the library's are refreshed). Returns library style id → local id. +/// the library's are refreshed). The variables a written copy binds are imported too and its +/// bindings point at their copies (never at this file's variables of the same ids). Returns library +/// style id → local id. pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet, update: bool) -> BTreeMap { let lid = lib.manifest.id.clone(); let mut out = BTreeMap::new(); let mut hashes = BTreeSet::new(); + // Local ids of the copies written now, with the library style they copy. + let mut written: Vec<(String, &Style)> = Vec::new(); for key in keys { let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue }; let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version); @@ -598,6 +634,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet { *slot = Style { id: r.local.clone(), ..style.clone() }; set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() }); + written.push((r.local.clone(), style)); } r.local } @@ -605,6 +642,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l)); doc.styles.push(Style { id: local.clone(), ..style.clone() }); set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version }); + written.push((local.clone(), style)); local } }; @@ -613,6 +651,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet } out.insert(key.clone(), local); } + let bound: BTreeSet = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect(); + if !bound.is_empty() { + let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update); + for (local, _) in &written { + if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) { + // Bindings to variables the library lacks are dropped (the published value stays). + slot.bound = + std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect(); + } + } + } copy_images(doc, &lib.doc, &hashes); out } @@ -1201,9 +1250,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r if !variables_unchanged(before, after) { return Err(READ_ONLY.into()); } - let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) }; - let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store); - if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) { + let changed_remote = |id| { + if !before.is_remote(id) && !after.is_remote(id) { + return false; + } + match (before.node_arc(id), after.node_arc(id)) { + (Some(old), Some(new)) => !Arc::ptr_eq(old, new), + _ => true, + } + }; + if after.dirty().iter().any(|id| changed_remote(*id)) { return Err(READ_ONLY.into()); } Ok(()) diff --git a/crates/engine/src/library_range_styles_tests.rs b/crates/engine/src/library_range_styles_tests.rs new file mode 100644 index 00000000..077470e7 --- /dev/null +++ b/crates/engine/src/library_range_styles_tests.rs @@ -0,0 +1,66 @@ +//! Text styles linked on character ranges travel with components (publish and place). + +use crate::Session; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn node(value: Value) -> NodeId { + NodeId(value["id"].as_u64().unwrap()) +} + +/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style). +fn fixture() -> (Session, NodeId, String, String) { + let mut s = Session::new(); + let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40}))); + let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"}))); + let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2})); + let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"}))); + let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":whole_text, "style":whole})); + let component = node(run(&mut s, "component.create", json!({"ids":[frame]}))); + let source = s.doc().unwrap().node(text).unwrap(); + let chars = source.kind.text().unwrap(); + assert!(source.styles.text.is_none()); + assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str())); + assert_eq!(chars.style_at(2).style_id, None); + (s, component, range, whole) +} + +#[test] +fn component_publish_keeps_range_text_style() { + let (mut s, component, range, whole) = fixture(); + run(&mut s, "library.publish", json!({"include":[component]})); + let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc; + assert!(snapshot.style(&whole).is_some()); + assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot"); +} + +#[test] +fn component_place_imports_range_text_style() { + let (mut source, component, range, whole) = fixture(); + let publication = run(&mut source, "library.publish", json!({})); + let library = source.doc().unwrap().published.as_ref().unwrap().clone(); + assert!(library.doc.style(&range).is_some()); + let mut target = Session::new(); + let path = "figcraft://library.test/range-styles"; + target.libraries.insert(path, Ok(library)); + run(&mut target, "library.add", json!({"path":path})); + let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component}))); + let doc = target.doc().unwrap(); + let children = &doc.node(instance).unwrap().children; + let ranged = doc.node(children[0]).unwrap().kind.text().unwrap(); + let full = doc.node(children[1]).unwrap().kind.text().unwrap(); + let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap(); + assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str())); + assert_eq!(ranged.style_at(0).font_size, 32.0); + assert!(ranged.style_at(2).style_id.is_none()); + let record = doc.libraries.styles.iter().find(|r| r.key == range); + assert!(record.is_some(), "character-range style missing after placement"); + assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str())); + assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some()); +} diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..27c9e616 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String { p.to_string_lossy().into_owned() } +fn dirty_library_document() -> (Document, NodeId) { + let mut doc = Document::new("Dirty library cache"); + let store = doc.ensure_library_store(); + let remote = doc.alloc_id(); + doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap(); + (doc, remote) +} + +/// An imported cache is dirty until the first edit; that alone is no edit of the library copies. +#[test] +fn read_only_allows_unchanged_predirty_remote_nodes() { + let (before, remote) = dirty_library_document(); + assert!(before.dirty().contains(&remote)); + + let mut after = before.clone(); + after.add_page("Unrelated page"); + assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set"); + check_read_only("page.add", &before, &after).unwrap(); + + let mut edited = before.clone(); + edited.get_mut(remote).unwrap().opacity = 0.5; + assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into())); + + let mut moved = before.clone(); + moved.reparent(remote, before.first_page().unwrap(), None).unwrap(); + assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into())); + + let mut deleted = before.clone(); + deleted.remove(remote).unwrap(); + assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into())); +} + /// The library file's layers. struct Kit { button: NodeId, @@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; + let unchanged = s.doc().unwrap().clone(); let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); @@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() { let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap(); let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") }; assert!(doc.is_remote(component)); + let store_roots = |s: &Session| { + let doc = s.doc().unwrap(); + doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len()) + }; + assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice"); // A second import shares the copies it already has. ex(&mut t, "file.import", json!({"path": path})); healthy(&t); assert_eq!(t.doc().unwrap().libraries.components.len(), 2); + assert_eq!(store_roots(&t), 2); let _ = std::fs::remove_dir_all(&d); } @@ -399,3 +445,21 @@ fn library_survives_saving_with_versions() { healthy(&user); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() { + use std::time::{Duration, SystemTime}; + let read_at = SystemTime::now(); + let entry = |modified: SystemTime| Entry { + stamp: Some(Stamp { len: 10, modified: Some(modified) }), + read_at: Some(read_at), + result: Err("unused".into()), + }; + // Written in the same coarse tick as the read: a same-length rewrite would keep this stamp. + let fresh = entry(read_at); + assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) })); + // Written well before the read: an unchanged stamp means an unchanged file. + let old = read_at - Duration::from_secs(10); + assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) })); + assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) })); +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4bbaad57..4fc778a7 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() { } assert!(s.doc().unwrap().libraries.is_empty()); } + +#[test] +fn library_style_bindings_ignore_unrelated_local_variables() { + let d = dir("style-bound"); + let path = path_str(&d.join("lib.figcraft")); + let mut lib = Session::new(); + ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"})); + let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone(); + let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone(); + ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var})); + let lid = publish_to(&mut lib, &path); + + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Local"})); + let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone(); + assert_eq!(local, var, "the ids must collide for this test"); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40}))); + ex(&mut s, "library.add", json!({"path": path})); + ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r})); + assert_eq!(fill(&s, r), "0000FF"); + healthy(&s); + + // An updated style follows the library's variable, not the local one. + ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, r), "00FFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/line_endings_tests.rs b/crates/engine/src/line_endings_tests.rs new file mode 100644 index 00000000..160707a6 --- /dev/null +++ b/crates/engine/src/line_endings_tests.rs @@ -0,0 +1,56 @@ +//! Text set by a command stores every line ending as LF, like editor insertion does, so layout +//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029. + +use serde_json::json; + +use crate::Session; +use crate::components::tests::{card, chars, copy_of, ex, idv}; +use crate::params::id_of; + +const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"]; + +#[test] +fn text_set_normalizes_line_endings() { + let mut s = Session::new(); + let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + for input in INPUTS { + s.execute("text.set", &json!({"id": id, "characters": input})).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.set {input:?}"); + } +} + +#[test] +fn text_create_normalizes_line_endings() { + let mut s = Session::new(); + for input in INPUTS { + let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.create {input:?}"); + } +} + +#[test] +fn find_replace_normalizes_line_endings() { + for input in INPUTS { + let mut s = Session::new(); + let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input})); + assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}"); + } +} + +#[test] +fn text_property_values_normalize_line_endings() { + let mut s = Session::new(); + let (c, _bg, title, _dot) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let copy = copy_of(&s, i, title); + for input in INPUTS { + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}})); + assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}"); + } +} diff --git a/crates/engine/src/open_issues_tests.rs b/crates/engine/src/open_issues_tests.rs index d7f23720..2bee799b 100644 --- a/crates/engine/src/open_issues_tests.rs +++ b/crates/engine/src/open_issues_tests.rs @@ -49,3 +49,16 @@ fn an_intact_file_opens_without_a_message() { assert!(s.message.is_none()); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + +#[test] +fn file_close_rejects_a_bad_index_instead_of_closing_another_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let docs = s.docs.len(); + for index in [json!(-1), json!(1.5), json!(99)] { + let r = s.execute("file.close", &json!({"index": index, "force": true})); + assert!(r.is_err(), "index {index} must be rejected, got {r:?}"); + assert_eq!(s.docs.len(), docs, "index {index} closed a document"); + } +} diff --git a/crates/engine/src/page_clipboard_tests.rs b/crates/engine/src/page_clipboard_tests.rs new file mode 100644 index 00000000..79ff7419 --- /dev/null +++ b/crates/engine/src/page_clipboard_tests.rs @@ -0,0 +1,36 @@ +//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a +//! selection of only pages under their own command id. + +use serde_json::json; + +use crate::{EngineError, Session}; + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + match s.execute(cmd, &json!({"ids": [page.0]})) { + Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd), + r => panic!("{cmd} on a page: {r:?}"), + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} + +#[test] +fn cut_of_a_page_and_a_layer_moves_only_the_layer() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = crate::params::id_of(&r["id"]).unwrap(); + s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap(); + assert!(s.doc().unwrap().node(page).is_some()); + assert!(s.doc().unwrap().node(rect).is_none()); + s.execute("edit.paste", &json!({})).unwrap(); + assert!(s.doc().unwrap().validate().is_empty()); +} diff --git a/crates/engine/src/page_copy_navigation_tests.rs b/crates/engine/src/page_copy_navigation_tests.rs new file mode 100644 index 00000000..ddbc956a --- /dev/null +++ b/crates/engine/src/page_copy_navigation_tests.rs @@ -0,0 +1,48 @@ +//! Duplicating a page copies its prototype connections: navigation between two frames that are both +//! on the page leads between the copies, while a destination outside the copied contents is kept. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn frame(s: &mut Session, x: f64) -> NodeId { + id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +fn dest(s: &Session, id: NodeId) -> NodeId { + s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap() +} + +#[test] +fn page_copy_navigation_uses_the_copied_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let children = s.doc().unwrap().node(copy).unwrap().children.clone(); + assert_eq!(children.len(), 2); + assert_eq!(dest(&s, children[0]), children[1]); + let visited = Default::default(); + assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1])); + assert_eq!(dest(&s, first), second, "the original keeps its destination"); +} + +#[test] +fn frame_copy_keeps_an_outside_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + ex(&mut s, "select.set", json!({"ids": [first]})); + ex(&mut s, "edit.duplicate", json!({})); + let copy = s.selection()[0]; + assert_ne!(copy, first); + assert_eq!(dest(&s, copy), second); +} diff --git a/crates/engine/src/page_target_tests.rs b/crates/engine/src/page_target_tests.rs new file mode 100644 index 00000000..fd88685d --- /dev/null +++ b/crates/engine/src/page_target_tests.rs @@ -0,0 +1,63 @@ +//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about +//! the layer changes, as `page.select` already does. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().get(id).unwrap().name.clone() +} + +#[test] +fn page_commands_reject_a_rectangle() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let before = name(&s, r); + assert!(s.execute("page.select", &json!({"id": r})).is_err()); + assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer"); + assert_eq!(name(&s, r), before, "the layer keeps its name"); + assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer"); + assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer"); + assert!(s.doc().unwrap().contains(r), "the layer is still there"); + assert!(s.doc().unwrap().contains(page)); +} + +#[test] +fn page_delete_keeps_a_frame_and_its_child() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}))); + assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f)); + assert!(s.execute("page.delete", &json!({"id": f})).is_err()); + assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain"); + assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err()); + assert_ne!(name(&s, f), "Not a page"); +} + +#[test] +fn page_commands_still_work_on_pages() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let second = id_of(&ex(&mut s, "page.add", json!({}))); + ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"})); + assert_eq!(name(&s, second), "Flows"); + ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"})); + match s.doc().unwrap().get(second).unwrap().kind { + NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"), + _ => panic!("not a page"), + } + ex(&mut s, "page.delete", json!({"id": second})); + assert!(!s.doc().unwrap().contains(second)); + assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays"); +} diff --git a/crates/engine/src/paint_index_tests.rs b/crates/engine/src/paint_index_tests.rs new file mode 100644 index 00000000..eddea306 --- /dev/null +++ b/crates/engine/src/paint_index_tests.rs @@ -0,0 +1,39 @@ +//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of +//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`). + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn paints(s: &Session, id: NodeId) -> String { + let n = s.doc().unwrap().node(id).unwrap(); + format!("{:?} {:?}", n.fills, n.strokes) +} + +#[test] +fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() { + let mut s = Session::new(); + let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + for add in ["fill.add", "stroke.add"] { + for c in ["#0000FF", "#FFFF00"] { + ex(&mut s, add, json!({"ids": [r], "color": c})); + } + } + let before = paints(&s, r); + for cmd in ["fill.set", "stroke.set"] { + for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] { + let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"})); + assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}"); + assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document"); + } + ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"})); + assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(paints(&s, r), before); + } +} diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index 3866d592..d299b0df 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); } + +#[test] +fn page_navigation_skips_dividers() { + // A trailing divider: Next page stays on the ordinary page. + let mut s = Session::new(); + let first = s.page().unwrap(); + let div = id(&ex(&mut s, "page.add", json!({"name": "-"}))); + assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open"); + let r = ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider"); + assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page"); + // A middle divider: Previous page crosses it, Next page crosses it back. + let later = id(&ex(&mut s, "page.add", json!({"name": "Later"}))); + assert_eq!(s.page().unwrap(), later); + let r = ex(&mut s, "view.prevPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider"); + assert_eq!(id_of(&r["page"]), Some(first)); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page crosses a divider"); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page stops on the last page"); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index bbaa42a4..35a74e0e 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// The offending value for an error message, cut to a few dozen characters so a huge param +/// doesn't end up in the error whole. +fn shown(v: &Value) -> String { + const MAX: usize = 40; + let text = v.to_string(); + match text.char_indices().nth(MAX) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text, + } +} + +/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a +/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — +/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't +/// resolved yet silently becomes "no parent" and the layer lands on the page. +pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))), + } +} + +/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none". +pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_index(cmd, p, key).map(Some), + } +} + /// `ids` (array), `id` (single), or the current selection. pub fn targets(s: &Session, p: &Value) -> Vec { if let Some(Value::Array(a)) = get(p, "ids") { @@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec { s.selection() } -/// Like [`targets`] but an empty result is an error. +/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an +/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit +/// `null` says the same as leaving the key out. pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + match get(p, "ids") { + Some(Value::Array(a)) => { + if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v)))); + } + } + Some(Value::Null) | None => { + if let Some(v) = get(p, "id").filter(|v| !v.is_null()) + && id_of(v).is_none() + { + return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v)))); + } + } + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))), + } let t = targets(s, p); if t.is_empty() { return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into())); diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..ce5aa0c6 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,10 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Network sessions, online files and their recovery copies on disk. + "collab.", + // Opens files in new windows. + "window.", "view.theme", ]; /// Read-only exceptions to [`DENIED`]. @@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + // `path` here is a file on disk (elsewhere it is a vector path or an action's place). + if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { + return Some("plugins can't write files: leave out `path` to get the result back".into()); + } + if command == "codeConnect.import" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the connections as `json`".into()); + } + // Variables JSON may be given as a path: only objects and JSON text for plugins. + if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) { + return Some("plugins can't read files: pass the variables JSON itself".into()); + } if !m.permissions.allows(command) { return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name)); } diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..657b6825 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("variable.export", json!({"format": "css", "path": target})), + ("codeConnect.export", json!({"path": target})), + ("codeConnect.import", json!({"path": "/etc/passwd"})), + ("component.playground", json!({"id": a, "path": target})), + ("variable.import", json!({"variables": "/etc/passwd"})), + ("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})), + ("window.new", json!({"path": "C:/Windows/win.ini"})), ("doc.switch", json!({"index": 0})), ("pref.keepTool", json!({})), // Without a name it would open inline renaming in the layers panel: a UI request. diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs new file mode 100644 index 00000000..4473ef7c --- /dev/null +++ b/crates/engine/src/save_format_tests.rs @@ -0,0 +1,366 @@ +//! Save As can write an experimental `.fig` copy without replacing native save state. + +use std::path::PathBuf; +use std::sync::Arc; + +use figcraft_format::Version; +use serde_json::{Value, json}; + +use crate::{DocState, EngineError, Session}; + +struct SaveFiles { + dir: PathBuf, + files: Vec, +} + +impl SaveFiles { + fn new() -> Self { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self { dir, files: Vec::new() } + } + + fn path(&mut self, name: &str) -> PathBuf { + let path = self.dir.join(name); + self.files.push(path.clone()); + path + } +} + +impl Drop for SaveFiles { + fn drop(&mut self) { + for path in &self.files { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(path); + } + let _ = std::fs::remove_dir(&self.dir); + } +} + +fn rect(s: &mut Session, name: &str) { + s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); +} + +fn assert_versions_unchanged(after: &[Version], before: &[Version]) { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!( + (&after.id, &after.name, &after.description, after.time, after.auto), + (&before.id, &before.name, &before.description, before.time, before.auto) + ); + assert_eq!(*after.document().unwrap(), *before.document().unwrap()); + } +} + +fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) { + let after = s.ds().unwrap(); + assert_eq!(after.path, before.path); + assert_eq!(after.is_dirty(), before.is_dirty()); + assert_eq!(after.versions_changed, before.versions_changed); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc)); + assert_eq!( + after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)), + before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)) + ); + if let (Some(after), Some(before)) = (&after.preview, &before.preview) { + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + assert_eq!((&after.selection, after.page), (&before.selection, before.page)); + assert_versions_unchanged(&after.versions, &before.versions); + assert_eq!(after.branches.len(), before.branches.len()); + for (after, before) in after.branches.iter().zip(&before.branches) { + assert_eq!( + (&after.id, &after.name, after.time, after.client, after.state), + (&before.id, &before.name, before.time, before.client, before.state) + ); + assert_eq!(*after.base, *before.base); + assert_eq!(*after.head, *before.head); + assert_versions_unchanged(&after.versions, &before.versions); + } + for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page)); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + } + assert_eq!(s.prefs.recent_files, recent); +} + +fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { + let bytes = std::fs::read(path).unwrap(); + assert_eq!(result["format"], "fig"); + assert_eq!(result["bytes"], bytes.len()); + assert!(result["warnings"].is_array()); + assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data"); + figcraft_format::fig::import(&bytes, "Copy").unwrap().0 +} + +#[test] +fn fig_save_as_keeps_native_path_dirty_state_and_history() { + let mut files = SaveFiles::new(); + let native = files.path("design.figcraft"); + let copy = files.path("copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Saved rectangle"); + s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "New rectangle"); + s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap(); + s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap(); + rect(&mut s, "Undone rectangle"); + s.undo().unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.is_dirty()); + assert!(!before.history.redo.is_empty()); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "New rectangle")); + assert!(!imported.nodes().any(|n| n.name == "Undone rectangle")); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out")); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle")); + assert_eq!(persisted.versions[0].name, "Before copy"); + assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize); +} + +#[test] +fn unsaved_fig_copy_remains_unsaved() { + let mut files = SaveFiles::new(); + let copy = files.path("unsaved.fig"); + let mut s = Session::new(); + rect(&mut s, "Unsaved rectangle"); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle")); + assert_save_state_unchanged(&s, &before, &recent); + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); +} + +#[test] +fn empty_document_fig_copy_keeps_clean_state() { + let mut files = SaveFiles::new(); + let copy = files.path("empty.fig"); + let mut s = Session::new(); + let before = s.ds().unwrap().clone(); + assert!(!before.is_dirty()); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert_eq!(imported.pages.len(), 1); + assert_eq!(imported.node_count(), 1); + assert_save_state_unchanged(&s, &before, &[]); +} + +#[test] +fn fig_save_commands_accept_uppercase_extension() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let copy = files.path(&format!("copy-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, command); + let before = s.ds().unwrap().clone(); + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == command)); + assert_save_state_unchanged(&s, &before, &[]); + } +} + +#[test] +fn fig_copy_from_branch_exports_active_document_without_saving_main() { + let mut files = SaveFiles::new(); + let native = files.path("main.figcraft"); + let copy = files.path("branch.fig"); + let mut s = Session::new(); + rect(&mut s, "Main rectangle"); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + rect(&mut s, "Branch rectangle"); + s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap(); + // Branch edits normally sync to main before commands; capture that state before exporting. + s.execute("branch.list", &json!({})).unwrap(); + let branch = s.ds().unwrap().clone(); + let main = s.docs[0].clone(); + let recent = s.prefs.recent_files.clone(); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle")); + assert_save_state_unchanged(&s, &branch, &recent); + assert_eq!(s.docs[0].path, main.path); + assert_eq!(s.docs[0].is_dirty(), main.is_dirty()); + assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc)); + assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc)); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); + assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); + assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!s.docs[0].is_dirty()); +} + +#[test] +fn fig_copy_write_failure_keeps_save_state() { + let mut files = SaveFiles::new(); + let blocked = files.path("blocked.fig"); + std::fs::create_dir(&blocked).unwrap(); + let missing = files.path("missing/copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Kept rectangle"); + s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap(); + rect(&mut s, "Later rectangle"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + for command in ["file.save", "file.saveAs"] { + for path in [&blocked, &missing] { + assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err()); + assert_save_state_unchanged(&s, &before, &[]); + assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists()); + } + } +} + +#[test] +fn save_as_without_extension_stays_native() { + let mut files = SaveFiles::new(); + let requested = files.dir.join("native"); + let native = files.path("native.figcraft"); + let mut s = Session::new(); + rect(&mut s, "Native rectangle"); + let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap(); + assert_eq!(result["path"], native.to_string_lossy().as_ref()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!requested.exists()); + let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle")); +} + +#[test] +fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let native = files.path(&format!("current-{index}.figcraft")); + let copy = files.path(&format!("version-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.preview.is_some()); + assert!(before.is_dirty()); + + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "A")); + assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + s.execute("version.exitPreview", &json!({})).unwrap(); + assert!(s.doc().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn pending_save_choice_keeps_version_preview() { + let mut files = SaveFiles::new(); + for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() { + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + if native_path { + let native = files.path(&format!("saved-{index}.figcraft")); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + } + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + } +} + +#[test] +fn native_save_from_version_preview_writes_current_document() { + let mut files = SaveFiles::new(); + for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() { + let native = files.path(&format!("native-version-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) }; + s.execute(command, ¶ms).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn implicit_save_protects_legacy_fig_destination() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let legacy = files.path(&format!("legacy-{index}.FIG")); + let native = files.path(&format!("corrected-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + // Earlier builds could write native data under .fig; keep those bytes safe on plain Save. + let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap(); + std::fs::write(&legacy, &sentinel).unwrap(); + s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned()); + s.ds_mut().unwrap().mark_saved(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + + s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert_eq!(loaded.versions[0].name, "A only"); + rect(&mut s, "C"); + s.execute("file.save", &json!({})).unwrap(); + let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "C")); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + } +} diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index b65b7936..bc961a4a 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -275,3 +275,92 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() { ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true})); assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); } + +#[test] +fn shift_drag_moves_the_selection() { + let mut s = Session::new(); + let k = scene(&mut s); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + let home = (Point::new(500.0, 0.0), Point::new(600.0, 0.0)); + // (selection before, Shift-drag to (from r at 520,20), selection after, where r and empty end up, derivation) + let rows: Vec<(Vec, (f64, f64), Vec, (Point, Point), &str)> = vec![ + ( + vec![k.r], + (560.0, 22.0), + vec![k.r], + (Point::new(540.0, 0.0), Point::new(600.0, 0.0)), + "Shift-drag on the selected layer moves it along one axis and keeps it selected", + ), + ( + vec![k.r, k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on one of several selected layers moves them all", + ), + ( + vec![k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on an unselected layer adds it and moves the selection", + ), + (vec![k.r, k.empty], (521.0, 21.0), vec![k.empty], home, "a Shift-press that wobbles under the drag threshold is a click: the layer leaves"), + ]; + for (before, to, after, end, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, (520.0, 20.0), to, SHIFT); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), end, "{why}"); + if end != home { + assert!(s.undo().unwrap(), "{why}: one undo step"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), home, "{why}: undone"); + } + } + // Shift let go before the button: still a Shift-click. + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).unwrap(); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, NONE)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); +} + +#[test] +fn alt_shift_drag_on_a_selected_layer_duplicates_it() { + let mut s = Session::new(); + let k = scene(&mut s); + let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let n = layers(&s); + let alt_shift = |kind, x, y| { + let mut e = ev(kind, x, y, SHIFT); + e.alt = true; + e + }; + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 520.0, 20.0)).unwrap(); + assert_eq!(s.selection(), Vec::::new(), "an Alt+Shift-click is a Shift-click: the layer leaves the selection"); + assert_eq!(layers(&s), n, "and nothing is copied"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Move, 560.0, 22.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 560.0, 22.0)).unwrap(); + let sel = s.selection(); + assert_eq!(layers(&s), n + 1, "an Alt+Shift-drag copies the layer"); + assert!(sel.len() == 1 && sel[0] != k.r, "the copy is selected"); + let doc = s.doc().unwrap(); + assert_eq!(doc.world_bounds(sel[0]).unwrap().origin(), Point::new(540.0, 0.0), "the copy moves along one axis"); + assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(500.0, 0.0), "the original stays"); +} + +#[test] +fn shift_click_takes_a_layer_out_where_nothing_can_move() { + let mut s = Session::new(); + let k = scene(&mut s); + // A version being viewed is read-only: the press can't start a move. + let v = ex(&mut s, "file.saveVersion", json!({"name": "v"})); + ex(&mut s, "version.preview", json!({"id": v["id"]})); + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + assert!(s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).is_err(), "a version being viewed can't be edited"); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, SHIFT)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift-click still takes the layer out"); +} diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 09a6758a..99d59b45 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { let whole_shape = touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x))); if merge && whole_shape { - return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick})); + return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } let doc = s.doc_mut()?; let created = if merge { diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 154c5ae1..62069da3 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -396,6 +396,30 @@ fn boolean_results_and_the_look_they_take() { assert!(path.area().abs() < 1e-6, "no overlap, nothing to show"); } +#[test] +fn flatten_keeps_open_paths() { + // #377: open paths have no fill outline, so two pen paths flattened to "nothing to flatten" + // and next to a closed shape they disappeared. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 50], [200, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 100], [100, 150], [200, 100]]})); + let tri = create(&mut s, "vector.create", json!({"points": [[300, 0], [400, 0], [400, 100]], "closed": true})); + let v = create(&mut s, "vector.flatten", json!({"ids": [a, b, tri]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("flatten makes one vector") }; + assert_eq!((network.vertices.len(), network.segments.len()), (9, 7), "both open paths and the triangle"); + assert_eq!(network.regions.len(), 1, "only the triangle is filled"); + let bb = s.doc().unwrap().world_bounds(v).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 400.0, 150.0), "nothing moves"); + + // Joining two open paths goes the same way. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]]})); + let v = create(&mut s, "vector.join", json!({"ids": [a, b]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("join makes one vector") }; + assert_eq!(network.segments.len(), 2); +} + #[test] fn flatten_and_outline_stroke() { // Flatten: one vector layer from the selection, in one step; Alt+Shift+F does it. diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs index 0cb3e148..c2a9f5d6 100644 --- a/crates/engine/src/synthetic.rs +++ b/crates/engine/src/synthetic.rs @@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { made += 4; } // A card group (5 layers). - if budget(made) >= 5 { + // The card and the instances leave one layer for the grid, which starts with a rectangle. + if budget(made) > 5 { let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; // Children are in the group's space; normalising fits the group around them. let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; @@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { } // Three button instances (3 layers each). for k in 0..3 { - if budget(made) < 3 { + if budget(made) < 3 + 1 { break; } crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?; @@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { for k in 0..rest { let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; - let id = match k % 5 { + // The grid starts with a rectangle so even the smallest workload has one. + let kind = (k + 2) % 5; + let id = match kind { 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, }; - if k % 5 != 0 { + if kind != 0 { fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); if let Some(n) = doc.node_mut(id) { n.corners.radii = [3.0; 4]; @@ -124,4 +127,16 @@ mod tests { assert!(doc.page_content_bounds(page).is_some()); } } + + #[test] + fn small_documents_hold_the_benchmark_targets() { + for n in [10, 11, 12, 13, 14, 15] { + let (doc, page) = super::large_document(n).unwrap(); + let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect(); + let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node)); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle"); + assert!(has(&|x| x.name == "Title"), "{n}: no title"); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component"); + } + } } diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 37c7dc5c..6ee8cc2c 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() { assert!(links(&s, t).iter().all(Option::is_none)); } +#[test] +fn previews_show_a_hovered_font_family() { + let mut s = new_session(); + let t = text(&mut s, "Preview me"); + s.ds_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + // Hovering a font family shows it on the canvas: no undo step, not dirty. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Arial"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert!(!s.ds().unwrap().is_dirty()); + // Moving to another family replaces the preview, from the document itself. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + // Closing without picking puts the document back as it was. + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true)); + assert_eq!(*s.doc().unwrap(), before); + // Clicking commits it as one undo step. + s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert!(s.ds().unwrap().text_preview.is_none()); +} + #[test] fn previews_refuse_what_they_cant_show() { let mut s = new_session(); diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index b951bfc7..81d05ed2 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() { assert_eq!(data(&s, t).list, P, "toggled off"); } +#[test] +fn a_list_with_a_start_number_toggles_off() { + // An imported list that starts at 2 has the start on its first item only; it is still one + // numbered list, so the button turns it back to plain (#686). + let mut s = Session::new(); + let t = text(&mut s, "two\nthree\nfour"); + if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { + let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + td.paragraphs = vec![n(1), n(0), n(0)]; + } + let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start"); + s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0); 3]); + assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start"); + assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none()); +} + #[test] fn typed_list_starts() { // Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one @@ -117,7 +135,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217..76b353a0 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> { let _ = doc.remove(te.id); s.ds_mut()?.selection.clear(); } - s.commit_interaction()?; if empty && te.created { - // Nothing to undo for a text box that was never filled. + // Nothing to undo for a text box that was never filled. The gesture snapshot was taken + // after the layer was added, so it still holds it: re-base it on the document without the + // layer, and committing then records no step (and leaves the redo stack alone). let ds = s.ds_mut()?; - if let Some(last) = ds.history.undo.last() - && *last.doc == *ds.doc - { - ds.history.undo.pop(); + let doc = ds.doc.clone(); + if let Some(it) = ds.interaction.as_mut() { + it.doc = doc; } } + s.commit_interaction()?; let ds = s.ds_mut()?; ds.revision += 1; Ok(()) @@ -82,12 +83,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = text - .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n") - .chars() - .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) - .collect(); + let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); if text.is_empty() { return Ok(()); } @@ -146,9 +142,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { } /// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like -/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is). +/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already +/// at the deepest level stay as they are). pub fn list_indent(s: &mut Session, out: bool) -> Result { - list_key(s, "Tab", out) + let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) }; + let before = text_of(s, id)?; + if !list_key(s, "Tab", out)? { + return Ok(false); + } + Ok(text_of(s, id)? != before) } /// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs index 507eccc3..da101d5e 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/textpreview.rs @@ -1,6 +1,6 @@ //! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a text style in the style browser or a font's named instance, or -//! while a variable font axis slider is dragged. The document under the preview is kept and comes +//! while the pointer is over a font family or style, a text style in the style browser or a +//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes //! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so //! committing (running the command for real) applies to the document itself as one undo step, and //! Escape just ends the preview. diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index feaf4c6d..5172bb4b 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } + +#[test] +fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { + let mut s = Session::new(); + ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone(); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"})); + ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true})); + ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25})); + ex(&mut s, "select.none", json!({})); + let source = s.page().unwrap(); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let frames = s.doc().unwrap().node(copy).unwrap().children.clone(); + let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()), + _ => panic!("expected a page"), + }; + let (flows, prototype, guides) = state(&s, copy); + let (src_flows, src_prototype, src_guides) = state(&s, source); + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].name, "Checkout"); + assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame"); + assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own"); + assert_eq!(prototype, src_prototype); + assert_eq!(prototype.device, "iPhone 16"); + assert!(prototype.responsive); + assert_eq!(guides, src_guides); + assert_eq!(guides.len(), 1); + assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0)); +} + +#[test] +fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() { + let mut s = Session::new(); + let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + ex(&mut s, "object.wrapInSection", json!({"ids": [start]})); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let section = doc.node(copy).unwrap().children[0]; + let frame = doc.node(section).unwrap().children[0]; + match &doc.node(copy).unwrap().kind { + NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::>(), vec![("Onboarding", frame)]), + _ => panic!("expected a page"), + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 733fc08c..dbda1894 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -344,6 +344,9 @@ pub(crate) enum Gesture { targets: Targets, duplicate: bool, moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, }, Resize { start: Point, @@ -1194,15 +1197,15 @@ impl Session { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { sel.clone() - } else if ev.shift { + } else if ev.shift && deselect.is_none() { let mut s2 = sel.clone(); - if let Some(pos) = s2.iter().position(|x| *x == target) { - s2.remove(pos); - } else { - s2.push(target); - } + s2.push(target); s2 } else if sel.contains(&target) { sel.clone() @@ -1210,12 +1213,12 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { - return Ok(()); - } // Locked layers selected in the layers panel stay put. let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); - if ids.is_empty() { + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } return Ok(()); } let mut targets = Targets::gather(&doc, page, &ids); @@ -1225,8 +1228,13 @@ impl Session { { targets.add_box(b); } - self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { @@ -1456,7 +1464,8 @@ impl Session { } Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), Gesture::PenDrag { anchor } => { - let out = ev.p() - *anchor; + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; if let Some(pen) = self.tool.pen.as_mut() && let Some(last) = pen.points.last_mut() { @@ -1734,7 +1743,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, start, .. } => { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -1776,7 +1785,11 @@ impl Session { } } else { self.cancel_interaction()?; - if !(ev.shift || ev.cmd || duplicate) { + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; } } @@ -1856,9 +1869,10 @@ impl Session { self.widget_click(ev.p(), ev.tol() * 2.0)?; return Ok(()); } - // The second press began a move or a resize: a double-click isn't one. - if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) { + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; self.cancel_interaction()?; } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index 536b2699..dcdd04e3 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -5,6 +5,79 @@ use serde_json::json; use crate::Session; use crate::params::id_of; +fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) { + let mut s = Session::new(); + let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap(); + let page = s.doc().unwrap().first_page().unwrap(); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap(); + s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap(); + s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap(); + s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let node = doc.get_mut(id).unwrap(); + node.name = "load-binding-sentinel".into(); + node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]; + node.width = 5.0; + node.source = Some(page); + node.overrides.insert("sentinel".into()); + doc.take_dirty(); + (doc, id) +} + +fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId { + let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap(); + assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into())); + assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0)); + assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill")); + node.id +} + +#[test] +fn initial_color_bindings_settle_in_constructor_and_loaders() { + let (stale, _id) = stale_bound_color_document(); + let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform; + + let mut state = Session::with_document(stale.clone()); + let id = assert_dark_bound_color(state.doc().unwrap(), name_transform); + assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page()); + assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel")); + assert!(!state.ds().unwrap().is_dirty()); + assert!(state.doc().unwrap().dirty().is_empty()); + assert_eq!(state.undo_depth(), (0, 0)); + state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + assert_dark_bound_color(state.doc().unwrap(), name_transform); + + let native = figcraft_format::save_bytes(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); + // A native file settles when it opens as a document, not in the loader. + let opened = Session::with_document(loaded); + let loaded = opened.doc().unwrap(); + let loaded_id = assert_dark_bound_color(loaded, name_transform); + assert_eq!(loaded_id, id); + assert_eq!(loaded.node(id).unwrap().source, stale.first_page()); + assert!(loaded.node(id).unwrap().overrides.contains("sentinel")); + assert!(loaded.dirty().is_empty()); + + let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap(); + assert_dark_bound_color(&loaded, name_transform); +} + +#[test] +fn initial_paint_settlement_preserves_existing_dirty_nodes() { + let (mut doc, _) = stale_bound_color_document(); + let page = doc.first_page().unwrap(); + doc.mark_dirty([page]); + + crate::cmd::styles::apply_paint_bindings_on_load(&mut doc); + + assert_eq!(doc.dirty().iter().copied().collect::>(), [page]); +} + #[test] fn bound_layers_follow_variable_values_and_unbind() { let mut s = Session::new(); diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs index 815fcb2b..9518911e 100644 --- a/crates/engine/src/variables_editor_tests.rs +++ b/crates/engine/src/variables_editor_tests.rs @@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() { assert_eq!(scope(f, "paddingHorizontal"), Some("GAP")); assert_eq!(scope(f, "visible"), None); } + +#[test] +fn create_collection_refuses_a_taken_name() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let before = s.doc().unwrap().variables.collections.len(); + assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err()); + assert_eq!(s.doc().unwrap().variables.collections.len(), before); + ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"})); + assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1); +} diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..8899dc4e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() { assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step"); } +#[test] +fn thin_strokes_are_easy_to_enter_and_bend() { + use crate::tools::ToolId; + // A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it. + let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0); + // (layer, screen px off the stroke, in edit mode after a double-click, derivation) + let rows: Vec<(&str, f64, bool, &str)> = vec![ + ("line", 0.0, true, "double-clicking a selected line edits it as a vector"), + ("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"), + ("line", 8.0, false, "a double-click well away from it does not"), + ("path", 4.0, true, "a stroke-only path reaches as far as a line does"), + ("path", 8.0, false, "and no further"), + ]; + for (layer, off, after, why) in rows { + let mut s = Session::new(); + let id = if layer == "line" { + s.set_tool(ToolId::Line); + drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE); + *s.selection().first().unwrap() + } else { + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap(); + // Styled like a pen stroke: 1 px, no fill. + ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + s.set_selection(vec![id]); + id + }; + assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke"); + let p = at(off); + click(&mut s, p, NONE); + s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap(); + assert_eq!(editing(&s).is_some(), after, "{why}"); + if after { + // Cmd/Ctrl then bends it through the pointer. + drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE }); + let w = s.doc().unwrap().world_transform(id); + let n = net(&s, id); + assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it"); + assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer"); + } + } +} + #[test] fn the_pen_follows_the_documented_rules() { let mut s = Session::new(); diff --git a/crates/engine/src/vector_offset_tests.rs b/crates/engine/src/vector_offset_tests.rs new file mode 100644 index 00000000..e47158f2 --- /dev/null +++ b/crates/engine/src/vector_offset_tests.rs @@ -0,0 +1,49 @@ +//! `vector.offset` on layers with children: containers are left alone. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = Session::new(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 093d0854..935f033a 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -3,7 +3,7 @@ //! `vectoredit.rs` re-fit the node afterwards. use figcraft_doc::*; -use figcraft_geom::kurbo::{CubicBez, ParamCurve}; +use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; fn tvec(t: (f64, f64)) -> Vec2 { @@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) { pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option { let s = net.segments.get(seg).copied()?; let c: CubicBez = net.cubic(seg)?; + // Which way each region loop walks `seg`, read before the split changes its endpoints. + let reversed: Vec> = net + .regions + .iter() + .map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect()) + .collect(); let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 }; let l = c.subsegment(0.0..t); let r = c.subsegment(t..1.0); @@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option Vec<(f64, if ia { b } else { a } }; let n = 64; + // Upper bound on the curve's speed: no point of a sample interval is farther than the whole + // interval length from either end, so only intervals that come that close need a closer look. + let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max); + let reach = speed / f64::from(n); + // A crossing narrower than an interval whose ends are both outside: the closest point. + let dip = |a: f64, b: f64| -> Option { + if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach { + return None; + } + let near = cb.subsegment(a..b).nearest(c, 1e-12); + let m = a + (b - a) * near.t; + (m > a && m < b && inside(m)).then_some(m) + }; let mut out = Vec::new(); let mut start: Option = if inside(0.0) { Some(0.0) } else { None }; let mut prev = 0.0; @@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64, let t = f64::from(i) / f64::from(n); match (start, inside(t)) { (None, true) => start = Some(edge(prev, t)), + (None, false) => { + if let Some(m) = dip(prev, t) { + out.push((edge(prev, m), edge(m, t))); + } + } (Some(s0), false) => { out.push((s0, edge(prev, t))); start = None; @@ -856,6 +882,38 @@ mod tests { assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}"); } + fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool { + let ends: Vec<(usize, usize)> = n + .loop_walk(lp) + .iter() + .map(|&(i, rev)| { + let s = n.segments[i]; + if rev { (s.b, s.a) } else { (s.a, s.b) } + }) + .collect(); + (0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0) + } + + #[test] + fn split_keeps_region_loop_in_ring_order() { + // Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false); + n.segments.push(Segment::line(0, 2)); + n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero }); + let p = Point::new(25.0, 10.0); + assert!(n.fill_contains(p)); + for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] { + let mut m = n.clone(); + m.regions[0].loops = vec![lp]; + let mid = split_segment(&mut m, seg, 0.5).unwrap(); + assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops); + assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill"); + if seg == 1 { + assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0)); + } + } + } + #[test] fn inferred_mirroring() { let mut n = open_line(); @@ -999,4 +1057,38 @@ mod tests { assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0)); assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0)); } + + #[test] + fn eraser_catches_narrow_crossing_between_samples() { + // A radius-1 brush on a long line: the crossing fits between the sampled parameters. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}"); + // A curved segment that only grazes the brush between samples. + let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + c.segments[0].ta = (0.0, 3000.0); + c.segments[0].tb = (0.0, 3000.0); + let cb = c.cubic(0).unwrap(); + let t = 0.7039; + let p = cb.eval(t); + let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize(); + let normal = Vec2::new(-d.y, d.x); + assert!(erase(&mut c, p + normal * 0.5, 1.0)); + assert_eq!(c.segments.len(), 2); + } + + #[test] + fn eraser_catches_crossing_at_full_speed_between_samples() { + // The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + let centre = n.cubic(0).unwrap().eval(32.5 / 64.0); + assert!(erase(&mut n, centre, 0.5)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}"); + } } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 388fe245..6667a209 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -996,8 +996,13 @@ impl Session { self.tool.overlay.guides.clear(); let Gesture::Vector(g) = g else { return Ok(()) }; match g { - VGesture::Points { moved, split, click_clears, .. } => { + VGesture::Points { verts, others, moved, split, click_clears, .. } => { if moved || split.is_some() { + if let ([v], None) = (verts.as_slice(), split) + && others.iter().all(|(_, o)| o.is_empty()) + { + self.merge_dropped_point(ve.id, *v, 6.0 / ev.zoom.max(1e-6))?; + } self.commit_interaction()?; } else { self.cancel_interaction()?; @@ -1057,4 +1062,40 @@ impl Session { } Ok(()) } + + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its + /// points, merges into that point (in the same undo step as the move), which stays selected. + fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { + let moved = self.ds()?.doc.get(id)?.clone(); + let w = self.ds()?.doc.world_transform(id); + let NodeKind::Vector { network: net } = &moved.kind else { return Ok(()) }; + let Some(p) = net.vertices.get(v).map(|x| w * x.point()) else { return Ok(()) }; + let Some(keep) = net + .vertices + .iter() + .enumerate() + .filter(|(i, _)| *i != v) + .map(|(i, x)| (i, (w * x.point() - p).hypot())) + .filter(|(_, d)| *d <= tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + else { + return Ok(()); + }; + let mut merged = net.clone(); + ops::merge_vertices(&mut merged, keep, v); + // A merge that would leave no segment (a 2-point line's ends dropped together) only moves. + if merged.segments.is_empty() { + return Ok(()); + } + // The preview starts again from the document before the drag: bring the moved layer. + self.preview(|doc, _| { + *doc.get_mut(id)? = moved; + edit(doc, id, |net| *net = merged) + })?; + set_state(self, |e| { + e.vertices = vec![if keep > v { keep - 1 } else { keep }]; + e.segments.clear(); + }) + } } diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index ae7ac27e..0eba28e3 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -280,6 +280,36 @@ fn mirroring_position_and_join_commands() { assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok()); } +#[test] +fn a_point_dropped_on_another_merges_into_it() { + // #401: an open path's end dragged onto its start closes the path at that point. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (0.0, 100.0), (2.0, 3.0)); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 3, "the dropped point merged"); + assert!((0..3).all(|v| n.incident(v).len() == 2), "a closed path: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 0.0, 0.0)], "the point it merged into stays selected"); + s.undo().unwrap(); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 4, "one undo step takes back the move and the merge"); + vertex_at(&n, 0.0, 100.0); + // A drop away from every point only moves it. + ex(&mut s, "vector.edit.select", json!({"vertices": []})); + drag(&mut s, (0.0, 100.0), (0.0, 50.0)); + assert_eq!(net(&s, id).vertices.len(), 4); + // A 2-point line's end dropped on its other end only moves: no segment would be left. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (100.0, 0.0), (2.0, 0.0)); + let n = net(&s, id); + assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}"); +} + #[test] fn undo_back_past_the_conversion_leaves_edit_mode() { let mut s = Session::new(); diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index c22cd1f5..27bc3771 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() { assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected"); } +#[test] +fn removing_an_earlier_guide_keeps_the_selected_guide() { + let mut s = Session::new(); + for offset in [100, 200, 300] { + ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset})); + } + let page = s.page().unwrap(); + ex(&mut s, "guide.select", json!({"index": 1})); + ex(&mut s, "guide.remove", json!({"index": 0})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index"); + assert!(s.tool_key("ArrowRight", false, false, false).unwrap()); + assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour"); + // A later guide leaves the selection alone. + ex(&mut s, "guide.remove", json!({"index": 1})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0))); +} + #[test] fn guides_snap_and_measure() { let mut s = Session::new(); diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index 7079905f..f228d35c 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -248,6 +248,19 @@ fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() { assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}"); } +#[test] +fn a_shift_click_on_a_selected_widget_only_deselects_it() { + let mut s = session(); + let (w, _) = insert(&mut s); + let target = s.doc().unwrap().world_bounds(node(&s, w).children[1]).unwrap().center(); + let mut down = PointerEvent::at(PointerKind::Down, target.x, target.y, 1.0); + down.shift = true; + // Shift let go before the button: still a Shift-click, not a click on the widget. + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, target.x, target.y, 1.0)).unwrap(); + assert_eq!((s.selection(), state(&mut s, w)), (vec![], json!({"n": 1}))); +} + #[test] fn bad_widgets_and_clicks_are_refused_and_roll_back() { let mut s = session(); diff --git a/crates/engine/tests/empty_text_box_undo.rs b/crates/engine/tests/empty_text_box_undo.rs new file mode 100644 index 00000000..ec73b83a --- /dev/null +++ b/crates/engine/tests/empty_text_box_undo.rs @@ -0,0 +1,60 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::NodeKind, + tools::{PointerEvent, PointerKind}, +}; +use serde_json::json; + +fn text_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count() +} + +fn rect_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s) +} + +fn click_and_abandon(s: &mut Session) { + s.execute("tool.text", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap(); + assert_eq!(text_count(s), 1); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(text_count(s), 0); +} + +#[test] +fn abandoning_empty_text_does_not_create_an_undo_step() { + let mut s = Session::new(); + assert_eq!(s.undo_depth(), (0, 0)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer"); +} + +#[test] +fn abandoning_empty_text_keeps_the_previous_undo_step_first() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + click_and_abandon(&mut s); + assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0); + assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle"); +} + +#[test] +fn abandoning_empty_text_keeps_the_redo_stack() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.undo_depth(), (0, 1)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rect_count(&s), 1); +} diff --git a/crates/engine/tests/library_style_acceptance.rs b/crates/engine/tests/library_style_acceptance.rs new file mode 100644 index 00000000..8398fc72 --- /dev/null +++ b/crates/engine/tests/library_style_acceptance.rs @@ -0,0 +1,72 @@ +//! Accepting a library update that changes only a paint style refreshes the layers linked to it. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_doc::{NodeId, StyleKind}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +fn layer_paint(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex() +} + +fn style_paint(s: &Session, id: &str) -> String { + match &s.doc().unwrap().style(id).unwrap().kind { + StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(), + _ => panic!("expected paint style"), + } +} + +struct Fixture(std::path::PathBuf); + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn accepted_library_paint_reaches_linked_layer() { + let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&fixture.0); + std::fs::create_dir(&fixture.0).unwrap(); + let path = fixture.0.join("library.figcraft"); + let mut publisher = Session::new(); + run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + let publication = run(&mut publisher, "library.publish", json!({})); + assert_eq!(publication["components"], 0); + assert_eq!(publication["styles"], 1); + run(&mut publisher, "file.saveAs", json!({"path":path})); + let mut consumer = Session::new(); + let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40})); + let layer = NodeId(rectangle["id"].as_u64().unwrap()); + run(&mut consumer, "library.add", json!({"path":path})); + let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer})); + let style = applied["style"].as_str().unwrap(); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"})); + run(&mut publisher, "library.publish", json!({})); + run(&mut publisher, "file.save", json!({})); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1); + let accepted = run(&mut consumer, "library.acceptUpdates", json!({})); + assert_eq!(accepted["styles"], 1); + assert_eq!(accepted["components"], 0); + assert_eq!(accepted["variables"], 0); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style)); + // Captured before any reapply so a later action can't hide a missed refresh. + let after_accept = layer_paint(&consumer, layer); + assert_eq!(after_accept, "00FF00"); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0); + consumer.undo().unwrap(); + assert_eq!(style_paint(&consumer, style), "0000FF"); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + consumer.redo().unwrap(); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); + run(&mut consumer, "style.apply", json!({"style":style,"id":layer})); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); +} diff --git a/crates/engine/tests/list_indent_response.rs b/crates/engine/tests/list_indent_response.rs new file mode 100644 index 00000000..1dae8754 --- /dev/null +++ b/crates/engine/tests/list_indent_response.rs @@ -0,0 +1,71 @@ +//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::{ListStyle, NodeId, NodeKind}, +}; +use serde_json::json; + +fn editing_list(indent: u8) -> (Session, NodeId) { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap(); + s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap(); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + (s, id) +} + +fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) { + let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") }; + let p = t.para_list(0); + (p.list, p.indent) +} + +#[test] +fn indent_at_cap_reports_false() { + let (mut s, id) = editing_list(8); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8)); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn indent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn outdent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listOutdent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id).0, ListStyle::None); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn indent_plain_text_reports_false() { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn tab_keys_still_nest_and_unnest() { + let (mut s, id) = editing_list(0); + s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0)); +} diff --git a/crates/engine/tests/scope_reset.rs b/crates/engine/tests/scope_reset.rs new file mode 100644 index 00000000..6d875d12 --- /dev/null +++ b/crates/engine/tests/scope_reset.rs @@ -0,0 +1,26 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::Session; +use serde_json::json; + +fn scopes(session: &Session, variable: &str) -> Option> { + session.doc().unwrap().variables.get(variable).unwrap().scopes.clone() +} + +#[test] +fn null_restores_all_supported_properties() { + let mut session = Session::new(); + session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap(); + let variable = result["id"].as_str().unwrap(); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true})); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap(); + assert_eq!(scopes(&session, variable), None); +} diff --git a/crates/engine/tests/shape_builder_response.rs b/crates/engine/tests/shape_builder_response.rs new file mode 100644 index 00000000..e1426823 --- /dev/null +++ b/crates/engine/tests/shape_builder_response.rs @@ -0,0 +1,36 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn rectangle(s: &mut Session, x: f64) -> Value { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone() +} + +#[test] +fn merge_response_includes_outlines_for_no_op() { + let mut s = Session::new(); + let id = rectangle(&mut s, 0.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 1); + let before = serde_json::to_value(s.doc().unwrap()).unwrap(); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before); + assert_eq!(result["id"], Value::Null); + assert_eq!(result["changed"], json!([])); + assert_eq!(result["removed"], json!([])); + assert_eq!(result["regions"], json!([0])); + assert_eq!(result.get("outlines"), Some(&json!([]))); +} + +#[test] +fn merge_response_control_includes_outlines() { + let mut s = Session::new(); + let a = rectangle(&mut s, 0.0); + let b = rectangle(&mut s, 50.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 3); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap(); + assert!(result["id"].is_u64()); + assert_eq!(result.get("outlines"), Some(&json!([]))); + assert_eq!(result.as_object().unwrap().len(), 5); +} diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index f3b869ff..4777be18 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() { assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]); } +#[test] +fn a_styled_layers_fill_variable_survives_a_round_trip() { + let mut doc = Document::new("Overlap"); + let page = doc.first_page().unwrap(); + let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; + doc.variables.collections = + vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }]; + let light = Color::new(0.1, 0.3, 0.7, 1.0); + let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0)); + doc.variables.variables = + vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])]; + doc.styles = vec![Style { + id: "s-accent".into(), + name: "Accent".into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(light)] }, + bound: Default::default(), + }]; + add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| { + n.fills = vec![Paint::solid(light)]; + n.styles.fill = Some("s-accent".into()); + n.bound.insert("fill".into(), "v-accent".into()); + }); + let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap(); + let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone(); + let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap(); + let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var); + let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap(); + let on_style = style.bound.get("fills/0/color") == Some(&var); + assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound); +} + #[test] fn text_styles_on_part_of_a_text_are_written_as_values() { let mut doc = styled(); diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs new file mode 100644 index 00000000..99730f70 --- /dev/null +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -0,0 +1,193 @@ +//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in +//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and +//! text that hugs by its resize mode. + +use figcraft_doc::{Document, Sizing}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; + +const SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE +enum StackMode NONE HORIZONTAL VERTICAL +enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE +enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextData string characters +message SymbolData GUID symbolID +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData +message Message NodeChange[] nodeChanges +"; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +fn at(x: f64, y: f64) -> Kv<'static> { + let f = |v: f64| Kv::Float(v as f32); + o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) +} + +fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> { + let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))]; + v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]); + v.append(&mut more); + o(v) +} + +/// An auto layout frame's fields: its direction and the sizing it records (`None`: none). +fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields { + let mut f = vec![("stackMode", Kv::Enum(mode))]; + f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p)))); + f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c)))); + f +} + +/// A text layer " text" in `parent`, with resize mode `resize` (`None`: none recorded). +fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))]; + f.push(("textData", o(vec![("characters", Kv::Str(name.into()))]))); + f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r)))); + node(id, parent, "!", "TEXT", &format!("{name} text"), f) +} + +/// A component at `x` holding a text layer, with `fields` of its own. +fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))]; + f.extend(fields); + vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)] +} + +fn import(nodes: Vec>) -> Document { + let mut all = vec![ + o(vec![ + ("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), + ("type", Kv::Enum("DOCUMENT")), + ("name", Kv::Str("D".into())), + ]), + o(vec![ + ("guid", g(1)), + ("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + ]; + all.extend(nodes); + let schema = Schema::parse(SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))])); + super::import(&bytes, "sizing").unwrap().0 +} + +fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) { + let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")); + (n.child_layout.sizing_h, n.child_layout.sizing_v) +} + +#[test] +fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + // (component, its auto layout fields, expected (horizontal, vertical), derivation) + let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![ + ("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"), + ("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"), + ("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"), + ("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"), + ("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"), + ]; + let mut nodes = Vec::new(); + for (k, (name, fields, _, _)) in rows.iter().enumerate() { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT"))); + } + let doc = import(nodes); + for (name, _, want, why) in rows { + assert_eq!(sizing(&doc, name), want, "{name}: {why}"); + } +} + +#[test] +fn text_hugs_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + let mut nodes = Vec::new(); + for (k, (name, resize)) in + [("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)] + .into_iter() + .enumerate() + { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize)); + } + // An instance of the auto width one: it hugs, and so does its copy of the text. + nodes.push(node( + 90, + 1, + "~", + "INSTANCE", + "Auto width instance", + [vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)] + .concat(), + )); + // One that records nothing of its auto layout: it has its main's. + nodes.push(node( + 91, + 1, + "~~", + "INSTANCE", + "Bare instance", + vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))], + )); + let doc = import(nodes); + for (name, want) in + [("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))] + { + assert_eq!(sizing(&doc, name), want, "{name}"); + } + let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap(); + assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug)); + let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap(); + assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug)); + let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap(); + assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal)); + assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug)); +} + +#[test] +fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let at = json!([[1, 0, 0], [0, 1, 0]]); + let text = |id: &str, name: &str, more: Value| { + let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name, + "style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}}); + t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + t + }; + let frame = |id: &str, name: &str, more: Value, kids: Value| { + let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16}, + "layoutMode": "HORIZONTAL", "children": kids}); + f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + f + }; + let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])), + frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}), + json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])), + ]}]}}); + let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap(); + // The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins. + for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] { + assert_eq!(sizing(&doc, name), want, "{name}"); + } +} diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs new file mode 100644 index 00000000..de0c4f2f --- /dev/null +++ b/crates/format/src/fig/connector_label_tests.rs @@ -0,0 +1,243 @@ +use super::{kiwi::Kv, map}; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn scalar(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))]; + fields.extend(children); + obj(fields) +} + +fn transform(x: f32, y: f32) -> Kv<'static> { + obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))]) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + obj(vec![("x", scalar(x)), ("y", scalar(y))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; // MoveTo + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); // LineTo + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn paint() -> Kv<'static> { + obj(vec![ + ("type", Kv::Enum("SOLID")), + ("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])), + ("visible", Kv::Bool(true)), + ]) +} + +fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> { + let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]); + let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]); + let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]); + let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]); + let mut derived = vec![line]; + if include_text { + derived.extend([text, background]); + } + let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]); + let text_style = record( + 1, + vec![ + ("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])), + ("fontSize", scalar(24.0)), + ("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])), + ("fillPaints", Kv::Array(vec![paint()])), + ("visible", Kv::Bool(false)), + ], + ); + let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]); + let mut styles = vec![line_style]; + if include_text { + styles.extend([text_style, bg_style]); + } + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![ + obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]), + obj(vec![ + ("guid", guid(1)), + ("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("synthetic connector".into())), + ("size", point(140.0, 50.0)), + ("opacity", scalar(0.5)), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", scalar(version)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("connectorStartCap", Kv::Enum("NONE")), + ("connectorEndCap", Kv::Enum("ARROW")), + ("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])), + ]), + ]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec) { + let (rest, warnings, _) = map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic", crate::rest::MAX_NODES); + (rest, warnings) +} + +fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node { + doc.nodes().find(|node| node.name == name).expect("native node") +} + +#[test] +fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() { + let (rest, warnings) = map_synthetic(1.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "FRAME"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit"); + let text = &connector["children"][1]; + assert_eq!(text["type"], "TEXT"); + assert_eq!(text["visible"], false); + assert_eq!(text["fills"].as_array().unwrap().len(), 1); + assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6); + assert_eq!(text["style"]["fontSize"], 24.0); + assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]])); + assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained"))); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let bytes = crate::save_bytes(&native, None).unwrap(); + let (reopened, _) = crate::load_bytes(&bytes).unwrap(); + let frame = find_node(&reopened, "synthetic connector"); + assert_eq!(frame.opacity, 0.5); + let text = find_node(&reopened, "synthetic label"); + assert!(!text.visible); + assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child"); + assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]); + assert_eq!(text.fills.len(), 1); + let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") }; + assert_eq!(data.style.font_size, 24.0); +} + +#[test] +fn synthetic_connector_missing_optional_derived_label_keeps_vector() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape"); + assert!(connector.get("children").is_none()); + assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); +} + +#[test] +fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert_eq!(connector["name"], "synthetic connector"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1)); + assert_eq!(connector["strokeCapEnd"], "ARROW"); + assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}"); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let node = find_node(&native, "synthetic connector"); + assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. })); +} + +#[test] +fn synthetic_connector_unsupported_version_uses_legacy_warning() { + let (rest, warnings) = map_synthetic(2.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector labels were skipped")); +} + +#[test] +fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { + let mut malformed_bounds = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0)); + let (rest, warnings, _) = map::to_rest(&malformed_bounds, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid")); + + let mut malformed_transform = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0)); + let (rest, warnings, _) = map::to_rest(&malformed_transform, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); +} + +#[test] +fn synthetic_connector_authored_hidden_background_is_retained() { + let mut message = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()])); + let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background"); + assert_eq!(background["visible"], false); + assert_eq!(background["fills"].as_array().unwrap().len(), 1); +} + +fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = value else { return None }; + fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) +} + +fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = message else { return None }; + let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?; + let Kv::Array(nodes) = node_changes else { return None }; + nodes.iter_mut().find(|node| { + node.get("type").and_then(Kv::str) == Some("CONNECTOR") + && node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64) + }) +} + +fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) { + let connector = connector_mut(message, 123).expect("synthetic connector"); + let section = field_mut(connector, field).expect("connector override section"); + let overrides = field_mut(section, "overrides").expect("override list"); + let Kv::Array(rows) = overrides else { panic!("override list array") }; + let row = rows + .iter_mut() + .find(|row| { + row.get("guidPath") + .and_then(|path| path.get("guids")) + .and_then(|ids| ids.list().first()) + .and_then(|id| id.get("localID")) + .and_then(Kv::f64) + == Some(local_id as f64) + }) + .expect("override row"); + let Kv::Object(fields) = row else { panic!("override row object") }; + if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) { + *current = value; + } else { + fields.push((key, value)); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 22243355..ca9e2682 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> { } } - /// How a layer sizes and sits in its auto layout parent. + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own + /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { let cl = &n.child_layout; + for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { + if w.is_some() || h.is_some() { + f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); + } + } let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; @@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> { if cl.absolute { f.push(("stackPositioning", e("ABSOLUTE"))); } - for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { - if w.is_some() || h.is_some() { - f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); - } - } if pl.mode == LayoutMode::Grid && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) { @@ -1501,7 +1502,6 @@ impl<'d> Exporter<'d> { ("textExplicitLayoutVersion", u(1)), ("textBidiVersion", u(1)), ("textTracking", fl(0.0)), - ("textDecorationSkipInk", b(true)), ("emojiImageSet", e("APPLE")), ]); if td.auto_resize == TextAutoResize::Truncate { @@ -1551,6 +1551,10 @@ impl<'d> Exporter<'d> { .map(|(st, id)| { let mut ef: Fields = vec![("styleID", u(id))]; style_fields(st, &mut ef); + // An empty link clears the text's own (a missing one would inherit it). + if st.link.is_none() && td.style.link.is_some() { + ef.push(("hyperlink", o(vec![("url", s(""))]))); + } if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); } @@ -1560,6 +1564,20 @@ impl<'d> Exporter<'d> { data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); } let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); + // Where each line's numbered list starts counting, less one: its first item's start, on + // every line of the list. + let mut run = 0; + let starts: Vec = lists + .iter() + .enumerate() + .map(|(i, l)| { + if first(i, l) { + run = td.para_list(i).start_offset; + } + if *l == ListStyle::Numbered { run } else { 0 } + }) + .collect(); // One entry per paragraph, each with every field a reader expects. let line = |(i, l): (usize, &ListStyle)| { let ty = match l { @@ -1568,14 +1586,15 @@ impl<'d> Exporter<'d> { ListStyle::Bulleted => "UNORDERED_LIST", }; let listed = *l != ListStyle::None; - let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); + let begins = first(i, l); + let start = starts.get(i).copied().unwrap_or(0); o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(0)), - ("isFirstLineOfList", b(first)), + ("listStartOffset", int(i64::from(start))), + ("isFirstLineOfList", b(begins)), ]) }; data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); @@ -1584,7 +1603,7 @@ impl<'d> Exporter<'d> { self.warn("nested list levels were written as the first level"); } let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); - if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) { + if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) { self.warn("text decoration looks and variable font settings were left out"); } if td.path.is_some() { @@ -1664,7 +1683,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions @@ -1833,12 +1858,13 @@ impl<'d> Exporter<'d> { let named = set.is_none_or(|s| s.name != n.name); let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); // Each layer below: its id path, the fields that differ from the main's layer, that layer's - // fields, and the component a nested instance was swapped for. + // fields, whether its size is its own (a nested instance, a size override) and the + // component a nested instance was swapped for. struct Row { path: Kv<'static>, changed: Fields, theirs: Fields, - nested: bool, + sized: bool, swap: Option>, } let mut rows: Vec = Vec::new(); @@ -1866,8 +1892,8 @@ impl<'d> Exporter<'d> { (NodeKind::Instance { component, .. }, _) => self.guid_of(*component), _ => None, }; - let nested = matches!(d.kind, NodeKind::Instance { .. }); - rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap }); + let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size"); + rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap }); } // An instance scaled as a whole (the Scale tool) is `factor` times its main in every length: // sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights. @@ -1902,8 +1928,8 @@ impl<'d> Exporter<'d> { let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]); for (k, v) in r.changed { let follows = follows(k, &v, &r.theirs); - // A nested instance's size is its own to set. - if follows && r.nested && k == "size" { + // A nested instance's size is its own to set, as is a size the layer overrides. + if follows && r.sized && k == "size" { of.push((k, v.clone())); } if follows { &mut df } else { &mut of }.push((k, v)); @@ -2388,6 +2414,13 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { Some((codes, false)) } +/// Whether the decoration look has more than skip ink, which the file can't hold. +fn unsupported_decoration_look(st: &TextStyle) -> bool { + let mut look = st.look(); + look.skip_ink = false; + look != Default::default() +} + /// The text style fields of a layer or of an override table entry. fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { @@ -2420,6 +2453,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), + // Skip ink only shows on an underline; other text gets the files' default (on), the value + // the import leaves out again. + ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 8d66b72f..56e9fcbf 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { n.source = None; n.overrides.clear(); n.constrain_proportions = false; + // A text's resize mode is its sizing (the editor keeps them together; generated documents + // don't): auto width hugs both axes, auto height its height. + if let NodeKind::Text(t) = &n.kind { + let cl = &mut n.child_layout; + let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s }; + match t.auto_resize { + TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug), + TextAutoResize::Height => hug(&mut cl.sizing_v), + _ => {} + } + } let fold = |ps: &mut Vec| { for p in ps.iter_mut() { if let PaintKind::Solid { color } = &mut p.kind { @@ -680,7 +691,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { for r in &mut td.runs { px(&mut r.style); } - td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect(); + td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect(); td.list = ListStyle::None; } NodeKind::Vector { network } => { @@ -1014,6 +1025,37 @@ fn junk_documents_never_panic() { } } +/// A frame's own min/max size survives whatever its parent is, not only under auto layout. +#[test] +fn frame_size_limits_survive_without_auto_layout_parent() { + fn limits(doc: &Document) -> [Option; 4] { + let (bytes, ew) = export(doc, None).unwrap(); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}"); + let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout; + [cl.min_w, cl.max_w, cl.min_h, cl.max_h] + } + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap(); + let node = doc.node_mut(id).unwrap(); + node.layout = Some(AutoLayout::default()); + node.child_layout.min_w = Some(80.0); + node.child_layout.max_w = Some(150.0); + node.child_layout.min_h = Some(60.0); + node.child_layout.max_h = Some(170.0); + let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)]; + + let mut nested = doc.clone(); + let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap(); + nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default()); + nested.reparent(id, wrapper, None).unwrap(); + assert_eq!(limits(&nested), expected, "auto layout parent"); + nested.node_mut(wrapper).unwrap().layout = None; + assert_eq!(limits(&nested), expected, "plain frame parent"); + assert_eq!(limits(&doc), expected, "page parent"); +} + /// Link each instance's layers to its main's by position, as they are imported (the engine /// links them by path after loading). fn link(doc: &mut Document) { diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs new file mode 100644 index 00000000..3fa50a20 --- /dev/null +++ b/crates/format/src/fig/image_filter_tests.rs @@ -0,0 +1,104 @@ +//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's +//! helpers. + +use super::*; + +/// The shared schema plus the image filter containers only these tests use. +fn image_filter_schema() -> Schema { + let text = SCHEMA + .replace( + "message Paint ", + "message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\ + message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ + message Paint ", + ) + .replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,"); + Schema::parse(&text).unwrap() +} + +#[test] +fn image_filters_import_from_current_container_and_skip_ambiguous_data() { + let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields); + let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| { + let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))]; + paint.append(&mut extra); + node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))]) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + image_paint( + 2, + "Current", + vec![( + "paintFilter", + filter(vec![ + ("exposure", f(0.25)), + ("contrast", f(0.3)), + ("vibrance", f(-0.4)), + ("temperature", f(0.5)), + ("tint", f(1.0)), + ("highlights", f(-0.6)), + ("shadows", f(0.7)), + ]), + )], + ), + image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]), + image_paint(4, "Neutral", vec![]), + image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]), + image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]), + image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]), + image_paint( + 8, + "Unsupported", + vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))], + ), + ]; + let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))]))); + let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap(); + let get = |name: &str| { + let node = doc.nodes().find(|node| node.name == name).unwrap(); + let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + let current = get("Current"); + assert!((current.filters.exposure - 0.25).abs() < 1e-6); + assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); + assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); + assert!((current.filters.temperature - 0.5).abs() < 1e-6); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint"); + assert!((current.filters.highlights + 0.6).abs() < 1e-6); + assert!((current.filters.shadows - 0.7).abs() < 1e-6); + assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); + assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6); + assert_eq!(get("Neutral").filters, Default::default()); + assert_eq!(get("Explicit neutral").filters, Default::default()); + assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice())); + assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}"); + for field in ["brightness", "detail", "vignette", "mystery"] { + assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}"); + } + assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); +} + +#[test] +fn warnings_naming_unknown_filter_fields_are_capped() { + // A schema can name any number of fields; each nonzero unknown one is a warning of its own. + let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect(); + let fields: Vec = names.iter().map(|n| format!("float {n}")).collect(); + let text = SCHEMA + .replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", "))) + .replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,"); + let filters = o(names.iter().map(|n| (*n, f(0.5))).collect()); + let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]), + ]; + let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))]))); + let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap(); + let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count(); + assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len()); +} diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..5c735b28 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,16 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a +/// large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is +/// lower: past it the file is refused cleanly instead of running out of memory. +const MIN_VALUES: usize = 20_000_000; +#[cfg(not(target_pointer_width = "32"))] +const MAX_VALUES: usize = 128 << 20; +#[cfg(target_pointer_width = "32")] +const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +292,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + packed.clamp(MIN_VALUES, MAX_VALUES) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +345,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index e0642f32..25540ff6 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,21 +19,29 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; +const MAX_GRID_TRACKS: usize = 1_000; +const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. -pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; + if !m.library_contexts.is_empty() { + out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -49,9 +58,13 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } +#[cfg(test)] +#[path = "map_image_filter_tests.rs"] +mod image_filter_tests; + /// REST importer warnings reworded for a `.fig` import (`None`: drop it). pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { @@ -70,15 +83,51 @@ fn num(n: &Kv, k: &str) -> Option { n.get(k).and_then(Kv::f64) } +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } +/// An override row names a layer of the main by its current GUID or by the `overrideKey` the +/// layer had when the row was written: the key becomes the current GUID, unless the layer is +/// current already or the key is ambiguous. +fn normalize_override_segment(path: &mut [String], index: usize, scope: &OverrideScope) { + let Some(segment) = path.get(index) else { return }; + if scope.current.contains(segment) { + return; + } + if let Some(Some(current)) = scope.aliases.get(segment) { + path[index] = current.clone(); + } +} + +#[cfg(test)] +#[path = "map_connector_snapshot_budget_tests.rs"] +mod connector_snapshot_budget_tests; + /// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. fn ref_guid(v: &Kv) -> Option { v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) } +/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An +/// explicit NO_STYLE in `key` is a detach and wins over the legacy field. +fn style_link(v: &View, key: &str, legacy: &str) -> Option { + match v.get(key).and_then(|r| r.get("guid")).and_then(guid) { + Some(id) => Some(id).filter(|g| g != NO_STYLE), + None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE), + } +} + fn color(c: &Kv) -> Value { let ch = |k: &str| num(c, k).unwrap_or(0.0); json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)}) @@ -115,6 +164,41 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { } } +/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. +fn text_num(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| { + layer.get(key).and_then(|value| match value { + Kv::Bool(_) => None, + _ => value.f64(), + }) + }) +} + +fn text_flag(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool)) +} + +fn insert_text_options(style: &mut Obj, v: &View) { + if let Some(value) = text_num(v, "listSpacing") { + style.insert("listSpacing".into(), json!(value)); + } + for k in ["hangingList", "hangingPunctuation"] { + if let Some(value) = text_flag(v, k) { + style.insert(k.into(), json!(value)); + } + } + if let Some(value) = leading_trim(v) { + style.insert("leadingTrim".into(), json!(value)); + } +} + +fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { + v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) { + Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value), + _ => None, + }) +} + /// A node seen through override layers: the last layer with a field wins. #[derive(Clone)] struct View<'a> { @@ -142,6 +226,16 @@ impl<'a> View<'a> { v } + /// This view with `style` just above the node itself: the style beats the node's own copy of + /// its fields (stale when the style changed since), and override rows beat both. + fn styled(&self, style: Option<&'a Kv<'a>>) -> Self { + let mut v = self.clone(); + if let Some(s) = style { + v.layers.insert(v.layers.len().min(1), s); + } + v + } + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { self.layers.iter().rev().find_map(|l| l.get(k)) } @@ -221,7 +315,7 @@ impl Fit { } /// Overrides and derived layout by id path. -type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; +type Overrides<'a> = BTreeMap, Vec>>; /// Where an override entry comes from. #[derive(Clone, Copy, PartialEq)] @@ -233,6 +327,25 @@ enum Entry { Stale, } +/// One override or derived-layout row addressed to a layer. +#[derive(Clone)] +struct OverrideRow<'a> { + row: &'a Kv<'a>, + entry: Entry, + /// How many mains are being expanded around the instance that wrote the row: the rows of + /// the enclosing (outer) instances win over those of the inner ones. + source_depth: usize, + /// Position among the rows of the instance that wrote it; later rows win. + ordinal: usize, +} + +/// The layers of a main by current GUID, and the old `overrideKey` of each that has one +/// (`None` when two layers claim the same key). +struct OverrideScope { + current: BTreeSet, + aliases: BTreeMap>, +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] @@ -241,6 +354,10 @@ struct Inst<'a> { path: Vec, overrides: Rc>, props: Rc>>, + /// Slot property GUID → private slot-content node GUID. + slot_contents: Rc>, + /// Layers imported from a slot's independent content subtree. + slot_content: bool, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, } @@ -259,18 +376,45 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// Layers and keys of each main, built when an instance of it first needs them. + override_scopes: BTreeMap>, + /// Entries held by `override_scopes` (bounded by `max_nodes` together). + override_scope_entries: usize, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, warnings: Vec, count: usize, + /// Most layers produced or scanned, instance expansion included; the one place the cap comes + /// from (see [`super::layer_budget`]). + max_nodes: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); - for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); + // The last change of a GUID wins; a REMOVED change deletes what came before it. + for n in changes { let Some(id) = n.get("guid").and_then(guid) else { continue }; if n.get("phase").and_then(Kv::str) == Some("REMOVED") { + nodes.remove(&id); + } else { + nodes.insert(id, n); + } + } + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) { continue; } if let Some(p) = n.get("parentIndex") @@ -285,7 +429,6 @@ impl<'a> Mapper<'a> { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); } } - nodes.insert(id, n); } for list in kids.values_mut() { list.sort(); @@ -302,7 +445,25 @@ impl<'a> Mapper<'a> { let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); } - Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + override_scopes: BTreeMap::new(), + override_scope_entries: 0, + library_contexts: Vec::new(), + library_scan_count: 0, + warnings: Vec::new(), + count: 0, + max_nodes, + networks: Vec::new(), + network_ids: HashMap::new(), + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -328,7 +489,8 @@ impl<'a> Mapper<'a> { fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Capped like the REST importer's list: some warnings name fields of the file (image filters). + if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } @@ -337,6 +499,50 @@ impl<'a> Mapper<'a> { self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default() } + /// The layers of main `main` by current GUID and by `overrideKey`. An instance nested in the + /// main is not entered: its layers belong to another main. + fn override_scope(&mut self, main: &str) -> Rc { + if let Some(scope) = self.override_scopes.get(main) { + return Rc::clone(scope); + } + let mut current = BTreeSet::new(); + let mut aliases: BTreeMap> = BTreeMap::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); + let mut complete = true; + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH || visited.len() >= self.max_nodes { + complete = false; + break; + } + if !visited.insert(id.clone()) { + continue; + } + let Some(node) = self.nodes.get(&id) else { continue }; + current.insert(id.clone()); + if let Some(alias) = node.get("overrideKey").and_then(guid) { + let target = aliases.entry(alias).or_insert_with(|| Some(id.clone())); + if target.as_deref().is_some_and(|target| target != id) { + *target = None; + } + } + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + } + // A scope cut short could name the wrong layer for a key: use none of the keys. + if !complete { + aliases.clear(); + } + let scope = Rc::new(OverrideScope { current, aliases }); + let cost = scope.current.len() + scope.aliases.len(); + if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) { + self.override_scope_entries += cost; + self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); + } + scope + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -345,6 +551,8 @@ impl<'a> Mapper<'a> { None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; let mut out = Vec::new(); + let mut internal = Vec::new(); + let mut visible_pages = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -353,14 +561,171 @@ impl<'a> Mapper<'a> { } // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { + internal.push((p, n)); continue; } + visible_pages.push(p.clone()); out.push(self.page(&p, n)); } + let required_mains = self.referenced_component_mains(&visible_pages); + for (id, canvas) in internal { + if let Some(context) = self.library_context(&id, canvas, &required_mains) { + self.library_contexts.push(context); + } + } if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc + } + + /// The ids of the components the visible pages use, directly or through the components they + /// use in turn (nested instances, swap targets, overrides naming another main). + fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet { + let mut mains = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect(); + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped")); + continue; + } + if !visited.insert(id.clone()) { + continue; + } + if visited.len() > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the component reference scan reached the {max}-layer limit")); + break; + } + let Some(node) = self.nodes.get(&id) else { continue }; + for target in self.component_main_targets(node) { + if mains.insert(target.clone()) { + pending.push((target, 1)); + } + } + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + mains + } + + /// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap + /// defaults and values of component properties. + fn component_main_targets(&self, node: &Kv<'a>) -> Vec { + let mut targets: Vec = node + .get("componentPropDefs") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|definition| { + self.is_instance_swap(definition.get("id")?)?; + definition.get("initialValue")?.get("guidValue").and_then(guid) + }) + .collect(); + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + return targets; + } + // The instance and the override rows below it (rows carry the same fields, nested). + let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)]; + let mut rows = 0usize; + while let Some((row, depth)) = pending.pop() { + rows += 1; + if depth > MAX_DEPTH || rows > self.max_nodes { + break; + } + let data = row.get("symbolData"); + targets.extend(row.get("overriddenSymbolID").and_then(guid)); + targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); + for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { + if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { + targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + } + } + let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); + let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default(); + pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1))); + } + targets + } + + fn is_instance_swap(&self, def: &Kv) -> Option<()> { + matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames + /// they sit in), as a frame of its own: the cached definitions of components that come from a + /// library. `None` when it holds none. + fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet) -> Option { + let mut included = BTreeSet::new(); + let mut parents: BTreeMap = BTreeMap::new(); + // The canvas itself is never a layer of the walk, whatever the parent links say. + let mut visited = BTreeSet::from([id.to_string()]); + // Each entry: layer, depth, inside a required definition. + let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect(); + while let Some((node_id, depth, in_definition)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped")); + continue; + } + if !visited.insert(node_id.clone()) { + continue; + } + self.library_scan_count += 1; + if self.library_scan_count > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped")); + break; + } + let Some(node) = self.nodes.get(&node_id) else { continue }; + let kind = node.get("type").and_then(Kv::str); + let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true))) + && required_mains.contains(&node_id); + let in_definition = in_definition || definition; + if in_definition { + included.insert(node_id.clone()); + } + if definition { + // The frames around it keep the path its layers are addressed by. + let mut ancestor = parents.get(&node_id); + for _ in 0..=MAX_DEPTH { + let Some(parent) = ancestor else { break }; + if !included.insert(parent.clone()) { + break; + } + ancestor = parents.get(parent); + } + } + for child in self.children(&node_id).into_iter().rev() { + // A layer reached again keeps the parent it was first reached by, so the parents + // lead back to the canvas. + if visited.contains(&child) { + continue; + } + parents.entry(child.clone()).or_insert_with(|| node_id.clone()); + pending.push((child, depth + 1, in_definition)); + } + } + if included.is_empty() { + return None; + } + let mut context = Obj::new(); + context.insert("id".into(), json!(id)); + context.insert("type".into(), json!("FRAME")); + copy_str(&mut context, &View::of(canvas), "name", "name"); + context.insert("size".into(), json!({"x": 0.0, "y": 0.0})); + context.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + context.insert(crate::rest::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false})); + self.variable_bindings(&mut context, &View::of(canvas)); + let mut children = Vec::new(); + for child in self.children(id) { + children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included))); + } + if !children.is_empty() { + context.insert("children".into(), Value::Array(children)); + } + Some(Value::Object(context)) } fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { @@ -405,34 +770,53 @@ impl<'a> Mapper<'a> { fit: Option, depth: usize, ) -> Option { + self.layer_filtered(id, inst, space, parent, fit, depth, None) + } + + /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the + /// top-level one: the cached library definitions of an internal canvas. + fn layer_filtered( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option { + if included.is_some_and(|ids| !ids.contains(id)) { + return None; + } if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let entries = match (inst, &path) { + let entries: &[OverrideRow<'a>] = match (inst, &path) { (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), _ => &[], }; - let mut view = View::of(base).with(entries.iter().map(|e| e.1)); + let mut view = View::of(base).with(entries.iter().map(|e| e.row)); // A layer of a resized instance the file gives no derived layout for follows its // constraints from the main's geometry, then takes the size and place overridden on it // (auto layout places the others, as in the editor). - let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); + let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); let main = View::of(base); - let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); + let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k)); if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { let t = main.transform().unwrap_or_default(); let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); - } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + } else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some())) && let Some(s) = set("size").and_then(size).or_else(|| main.size()) { view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); @@ -442,26 +826,54 @@ impl<'a> Mapper<'a> { _ => id.to_string(), }; let own = view.str("type").unwrap_or(""); - let stand = stand_in(own); + let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); + // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. + let connector_terminal = + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); + // A supported connector snapshot has its own note below instead of the generic stand-in one. + let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { "ROUNDED_RECTANGLE" => "RECTANGLE", "SYMBOL" => "COMPONENT", "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", "VARIABLE" | "VARIABLE_SET" => return None, + "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", + "CONNECTOR" if connector_snapshot => "FRAME", t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } + if connector_snapshot { + self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); + } self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); + // Private provenance for the .fig importer; ordinary REST import ignores it. + o.insert( + crate::rest::FIG_SOURCE_FIELD.into(), + json!({ + "origin": id, + "expanded": inst.is_some(), + "wireFrame": own == "FRAME", + "wireInstance": own == "INSTANCE", + "slotContent": inst.is_some_and(|i| i.slot_content), + // A size the instance gives this layer itself (kept when the instance relays out). + "sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()), + }), + ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -477,23 +889,64 @@ impl<'a> Mapper<'a> { } _ => {} } - if own == "CONNECTOR" { + if own == "CONNECTOR" && connector_snapshot { + if connector_terminal { + self.connector_snapshot_flatten(&mut o, &view, &rest_id, space); + } else { + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { + o.remove(key); + } + o.insert("clipsContent".into(), json!(false)); + } + } else if own == "CONNECTOR" { self.connector(&mut o, &view, space); } if ty == "COMPONENT" || ty == "COMPONENT_SET" { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); + let slot_content = if own == "FRAME" { + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + } else { + None + }; + if slot_content.is_some() + && let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) + { + source.insert("slotOverride".into(), json!(true)); + } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = Vec::new(); - if ty == "INSTANCE" { + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + if connector_terminal { + // The saved vector is flattened into the connector node when no child slot remains. + } else if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); - let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); + let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); + // An instance that records no auto layout of its own has its main's. + let view = match main { + Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { + let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; + self.auto_layout(&mut o, &layered); + layered + } + _ => view.clone(), + }; for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); + // The main's branch can live outside the cached context being mapped. + kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } + } else if let Some(content) = slot_content { + let mut content_inst = inst.cloned(); + if let Some(content_inst) = &mut content_inst { + content_inst.slot_content = true; + } + for c in self.children(&content) { + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + } } else { // Inside an instance a layer's children are its main's: they follow it from the main's // size to its own. @@ -503,7 +956,7 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); } } if stand.is_some_and(|s| s.0 == "FRAME") { @@ -521,7 +974,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -553,7 +1006,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -584,10 +1037,10 @@ impl<'a> Mapper<'a> { o.insert(to.into(), json!(cap_name(c))); } } - if own_text(v).is_some() { + if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -624,6 +1077,207 @@ impl<'a> Mapper<'a> { ); } + fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { + if v.str("connectorType") != Some("MANUAL") + || v.get("connectorVersion").and_then(Kv::f64) != Some(1.0) + || v.str("connectorLineStyle") != Some("STRAIGHT") + || v.get("derivedImmutableFrameData").and_then(|data| data.get("version")).and_then(Kv::f64) != Some(1.0) + { + return false; + } + let Some(line) = connector_record(v, "derivedImmutableFrameData", 0) else { return false }; + if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() { + return false; + } + let geometry = ["fillGeometry", "strokeGeometry"].iter().any(|key| { + line.get(key).map(Kv::list).unwrap_or_default().iter().any(|path| { + num(path, "commandsBlob") + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| svg_path(blob)) + .is_some_and(|data| !data.is_empty()) + }) + }); + let network = line + .get("vectorData") + .and_then(|data| num(data, "vectorNetworkBlob")) + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| network::decode(blob)) + .is_some_and(|network| !network.segments.is_empty()); + (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() + } + + fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) { + let parent = space * v.get("transform").and_then(matrix).unwrap_or_default(); + let Some(vector) = self.connector_snapshot_vector(v, rest_id, parent, false) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return; + }; + for key in [ + "size", + "relativeTransform", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "styles", + "fillGeometry", + "strokeGeometry", + "vectorNetwork", + ] { + if let Some(value) = vector.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + if vector.get("visible").and_then(Value::as_bool) == Some(false) { + out.insert("visible".into(), json!(false)); + } + if let Some(line_opacity) = vector.get("opacity").and_then(Value::as_f64) { + let root_opacity = out.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + out.insert("opacity".into(), json!(root_opacity * line_opacity)); + } + if connector_has_saved_label(v) { + let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; + self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); + } + } + + /// Build the saved vector child or flattened terminal connector from the same child-only view. + fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option { + let (Some(line), Some(line_style)) = (connector_record(v, "derivedImmutableFrameData", 0), connector_record(v, "nodeGenerationData", 0)) + else { + return None; + }; + let mut vector = Obj::new(); + vector.insert("id".into(), json!(if nested { format!("{rest_id};vector") } else { rest_id.to_string() })); + vector.insert("type".into(), json!("VECTOR")); + if let Some(name) = v.str("name") { + vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() })); + } + let line_view = View::of(line).with([line_style]); + self.common(&mut vector, &line_view, "VECTOR", space, None); + self.geometry(&mut vector, &line_view); + for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { + if let Some(cap) = v.str(from) { + vector.entry(to).or_insert_with(|| json!(cap_name(cap))); + } + } + Some(vector) + } + + /// Snapshot the straight, manual connector contract carried by its generated children. + fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); + return Vec::new(); + } + let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return Vec::new(); + }; + let mut children = Vec::new(); + children.push(Value::Object(vector)); + self.count += 1; + + let Some(text_frame) = connector_record(v, "derivedImmutableFrameData", 1) else { + if connector_has_saved_label(v) { + self.warn("connector label was skipped: derived text bounds are missing"); + } + return children; + }; + let Some(background) = connector_record(v, "derivedImmutableFrameData", 2) else { + if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: derived background bounds are missing"); + } + return children; + }; + let Some(text_style) = connector_record(v, "nodeGenerationData", 1) else { + if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: text style data is missing"); + } + return children; + }; + let text_view = View::of(text_frame).with([text_style]); + let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else { + return children; + }; + let Some(text_transform) = text_frame.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: text transform is invalid"); + return children; + }; + let Some(background_transform) = background.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: background transform is invalid"); + return children; + }; + let Some(text_size) = valid_size(text_frame.get("size")) else { + self.warn("connector label was skipped: text bounds are invalid"); + return children; + }; + let Some(background_size) = valid_size(background.get("size")) else { + self.warn("connector label was skipped: background bounds are invalid"); + return children; + }; + let composed_text_transform = background_transform * text_transform; + if !composed_text_transform.as_coeffs().iter().all(|coefficient| coefficient.is_finite()) { + self.warn("connector label was skipped: composed text transform is invalid"); + return children; + } + + let Some(background_style) = connector_record(v, "nodeGenerationData", 2) else { + self.warn("connector label background was skipped: style data is missing"); + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + return children; + }; + let background_view = View::of(background).with([background_style]); + if !background_view.list("fillPaints").is_empty() { + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); + } else { + let mut rect = Obj::new(); + rect.insert("id".into(), json!(format!("{rest_id};label-background"))); + rect.insert("type".into(), json!("RECTANGLE")); + rect.insert("name".into(), json!("connector label background")); + self.common(&mut rect, &background_view, "RECTANGLE", Affine::IDENTITY, None); + rect.insert("size".into(), json!({"x": background_size.0, "y": background_size.1})); + rect.insert("relativeTransform".into(), rows(background_transform)); + rect.insert("clipsContent".into(), json!(false)); + children.push(Value::Object(rect)); + self.count += 1; + } + } + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + children + } + + fn add_connector_text( + &mut self, + children: &mut Vec, + rest_id: &str, + characters: &str, + size: (f64, f64), + transform: Affine, + view: &View<'a>, + ) { + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); + return; + } + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label-text"))); + text.insert("type".into(), json!("TEXT")); + text.insert("name".into(), json!(characters)); + text.insert("size".into(), json!({"x": size.0, "y": size.1})); + self.common(&mut text, view, "TEXT", Affine::IDENTITY, None); + text.insert("relativeTransform".into(), rows(transform)); + self.text(&mut text, view); + children.push(Value::Object(text)); + self.count += 1; + } + /// Fields every layer type shares. fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) { for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] { @@ -643,14 +1297,18 @@ impl<'a> Mapper<'a> { } let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); - // Paints, from the shared style when one is linked (the layer's own copy can be stale). + // Paints, from the shared style when one is linked (the layer's own copy can be stale), + // unless the layer's own paints are bound to variables: those bindings must not be lost. let mut styles = Obj::new(); + let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound)); let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); - let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); - o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); + let fills = own("fillPaints") + .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); - let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); - o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); + let strokes = own("strokePaints") + .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); if !effects.is_empty() { @@ -715,7 +1373,7 @@ impl<'a> Mapper<'a> { /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST /// `styles` map under `slot`. fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { - let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?; + let id = style_link(v, key, legacy)?; let node = *self.nodes.get(&id)?; node.get("styleType").and_then(Kv::str)?; out.insert(slot.into(), json!(id)); @@ -758,7 +1416,10 @@ impl<'a> Mapper<'a> { }; o.insert("counterAxisAlignItems".into(), json!(cross)); let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" }; - o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing"))); + // A flow that records no sizing for its primary axis sizes it by its content: auto layout + // did before that axis could be fixed. + let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") }; + o.insert("primaryAxisSizingMode".into(), json!(primary)); o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing"))); if let Some(b) = v.flag("bordersTakeSpace") { o.insert("strokesIncludedInLayout".into(), json!(b)); @@ -771,35 +1432,104 @@ impl<'a> Mapper<'a> { copy_num(o, v, "gridRowGap", "gridRowGap"); o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len())); o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len())); - let sizes = ["gridColumnsSizing", "gridRowsSizing"] - .iter() - .flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default()); - let flexible = sizes.into_iter().all(|e| { - let t = e.get("trackSize"); - ["minSizing", "maxSizing"] - .iter() - .all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX")) - }); - if !flexible { - self.warn("grid tracks of fixed or content size import as equal tracks"); + let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")] + .into_iter() + .filter_map(|(out_key, order_key, sizing_key)| { + self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped)) + }) + .collect::(); + if !track_data.is_empty() { + let source = o.entry(crate::rest::FIG_SOURCE_FIELD).or_insert_with(|| json!({})); + if let Some(source) = source.as_object_mut() { + source.insert("gridTracks".into(), Value::Object(track_data)); + } } } } + /// Track sizing maps are keyed by GUID; their order comes from the separate track list. + fn map_grid_tracks(&mut self, v: &View<'a>, order_key: &str, sizing_key: &str) -> Option { + let track_entries = v.get(order_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + let sizes = v.get(sizing_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + if track_entries.len() > MAX_GRID_TRACKS { + self.warn("grid track list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + if sizes.is_empty() { + return None; + } + if sizes.len() > MAX_GRID_TRACKS { + self.warn("grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + let ordered = tracks(v, order_key); + let ordered_ids: BTreeSet<&str> = ordered.iter().map(String::as_str).collect(); + let mut size_by_id: BTreeMap>> = BTreeMap::new(); + for entry in sizes { + let Some(id) = entry.get("id").and_then(guid) else { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + }; + if !ordered_ids.contains(id.as_str()) { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + } + match size_by_id.entry(id) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(Some(entry)); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + slot.insert(None); + self.warn("duplicate grid track sizing entries use equal tracks"); + } + } + } + let mut mapped = Vec::with_capacity(ordered.len()); + for id in ordered { + if let Some(Some(entry)) = size_by_id.get(&id) { + match grid_track_sizing(entry.get("trackSize")) { + Some(track) => mapped.push(track), + None => { + self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing"); + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + } else { + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + Some(Value::Array(mapped)) + } + fn text(&mut self, o: &mut Obj, v: &View<'a>) { - let style_node = v - .get("styleIdForText") - .and_then(ref_guid) - .or_else(|| v.get("inheritTextStyleID").and_then(guid)) - .and_then(|s| self.nodes.get(&s).copied()); - let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); + let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); + // Instance overrides beat the shared style; the style beats the layer's own copy. + let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); let td = v.get("textData"); let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); o.insert("characters".into(), json!(chars)); let mut style = text_style(&sv, None); + // The style's typography beats the layer's own copy of it (the copy is stale when the style + // changed). The file cannot tell a stale copy of these four values from a deliberate local + // one, and "original", "none" and 0 could never be set locally if the style always won, so + // a value the layer or an override sets itself is taken as deliberate. + if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { + style.insert("textDecoration".into(), json!(decoration)); + } + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + style.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } + if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { + style.insert("textCase".into(), json!(case)); + } for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] { copy_str(&mut style, v, k, k); } + // Paragraph settings belong to the effective text layer, including explicit zero clears. + for k in ["paragraphSpacing", "paragraphIndent"] { + copy_num(&mut style, v, k, k); + } + insert_text_options(&mut style, v); if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -809,24 +1539,9 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); - if ids.iter().any(|i| *i != 0) { - let mut table = Obj::new(); - for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { - let Some(sid) = num(e, "styleID") else { continue }; - let mut s = text_style(&View::of(e), size); - if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); - } - table.insert((sid as u64).to_string(), Value::Object(s)); - } - o.insert("characterStyleOverrides".into(), json!(ids)); - o.insert("styleOverrideTable".into(), Value::Object(table)); - } - let lines: Vec<&str> = td - .get("lines") - .map(Kv::list) - .unwrap_or_default() + char_styles(o, td, size, &mut |w| self.warn(w)); + let raw = td.get("lines").map(Kv::list).unwrap_or_default(); + let lines: Vec<&str> = raw .iter() .map(|l| match l.get("lineType").and_then(Kv::str) { Some("ORDERED_LIST") => "ORDERED", @@ -837,6 +1552,98 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // Where a numbered list starts counting, less one (not a REST field). + let starts: Vec = + raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); + if starts.iter().any(|n| *n > 0) { + o.insert("listStartOffsets".into(), json!(starts)); + } + } + + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some(net) = network::decode(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); } /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. @@ -875,19 +1682,38 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + } + if has_missing { + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); @@ -895,34 +1721,24 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + // An absent layer join is the source default. + let layer_join = v.str("strokeJoin").unwrap_or("MITER"); + let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin")); + let known = |j: &str| matches!(j, "MITER" | "BEVEL" | "ROUND"); + let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none()); + let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known))); + let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join)); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } - let join = v.str("strokeJoin"); - if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) { - self.warn("points with their own stroke join use the layer's"); + if unresolved_style { + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); + } + if unknown_join { + self.warn("vector points with unknown stroke joins use the layer join"); + } + if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { + self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); } // The stroke starts at the first end point and stops at the last (as the editor draws it). let mut degree = vec![0usize; net.vertices.len()]; @@ -942,7 +1758,49 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) { + out["join"] = json!(join.to_ascii_lowercase()); + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } @@ -963,6 +1821,11 @@ impl<'a> Mapper<'a> { None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), + "SLOT" => { + self.warn_non_default_slot_config(d); + self.warn_dropped_slot_preferred_values(d); + json!({"type": "SLOT"}) + } _ => { self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase())); continue; @@ -979,6 +1842,34 @@ impl<'a> Mapper<'a> { } } + /// Preferred slot values are source component keys, but this mapper has no proven way to map + /// those keys to file GUIDs. They are dropped instead of guessing native NodeIds. + fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) { + let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default(); + if !entries.is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + } + + fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) { + let Some(config) = def.get("slotPropConfig") else { return }; + let non_default = [ + ("stretchChildOnInsert", Some(false)), + ("displayByDefault", Some(false)), + ("minChildren", None), + ("maxChildren", None), + ("allowPreferredValuesOnly", Some(false)), + ] + .into_iter() + .any(|(field, bool_default)| { + let Some(value) = config.get(field) else { return false }; + if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) } + }); + if non_default { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// A layer's component property references, and inside an instance the values they take. fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) { let mut refs = Obj::new(); @@ -987,6 +1878,7 @@ impl<'a> Mapper<'a> { Some("VISIBLE") => "visible", Some("TEXT_DATA") => "characters", Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent", + Some("SLOT_CONTENT_ID") => "slot", _ => continue, }; let Some(def) = @@ -994,7 +1886,11 @@ impl<'a> Mapper<'a> { else { continue; }; - if let Some((name, _)) = self.prop_defs.get(&def) { + // Expanded frames must follow their main frame's source link. A direct slot reference + // makes the engine resolve ownership against the instance, where the property is not defined. + if !(field == "slot" && inst.is_some()) + && let Some((name, _)) = self.prop_defs.get(&def) + { refs.insert(field.into(), json!(name)); } let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; @@ -1007,6 +1903,16 @@ impl<'a> Mapper<'a> { "characters" => { if let Some(t) = prop_text(val) { o.insert("characters".into(), json!(t)); + // Run styles count the chars they were made for: for other chars only + // the value's own apply. + if own_text(v) != Some(t) { + o.remove("characterStyleOverrides"); + o.remove("styleOverrideTable"); + if let Some(tv) = val.get("textValue") { + let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); + char_styles(o, tv, size, &mut |w| self.warn(w)); + } + } } } _ => {} @@ -1060,7 +1966,18 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let scope = self.override_scope(&sym); + let mut overrides: Overrides<'a> = BTreeMap::new(); + if let Some(inherited) = outer { + for (path, rows) in inherited.overrides.iter() { + let mut path = path.clone(); + // Rows an outer instance wrote for this instance's layers may use their old keys. + if path.starts_with(&own_path) && path.len() > own_path.len() { + normalize_override_segment(&mut path, own_path.len(), &scope); + } + overrides.entry(path).or_default().extend(rows.iter().cloned()); + } + } // A nested instance's derived layout is for the size it has in its main: resized here, the // sizes and places in it no longer hold and its layers follow their constraints instead. let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); @@ -1069,40 +1986,86 @@ impl<'a> Mapper<'a> { (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), (a, b) => a.is_some() != b.is_some(), }; + let mut local: Overrides<'a> = BTreeMap::new(); let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; + let source_depth = outer.map_or(0, |i| i.symbols.len()); + let mut ordinal = 0; for (layer, list) in lists.into_iter().enumerate() { for ov in list { - let rel: Vec = + let row_ordinal = ordinal; + ordinal += 1; + let mut rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); if rel.is_empty() || rel == [sym.clone()] { continue; } - let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); - // The outer instance's overrides win over this one's own; derived layout comes last. - if layer == 0 && outer.is_some() { - e.insert(0, (Entry::Override, ov)); - } else { - e.push(( - match (layer, resized) { - (0, _) => Entry::Override, - (_, false) => Entry::Derived, - (_, true) => Entry::Stale, - }, - ov, - )); - } + normalize_override_segment(&mut rel, 0, &scope); + // Explicit overrides, then derived layout (stale once the instance is resized). + let entry = match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }; + local.entry([own_path.as_slice(), &rel].concat()).or_default().push(OverrideRow { + row: ov, + entry, + source_depth, + ordinal: row_ordinal, + }); } } + for (path, rows) in local { + overrides.entry(path).or_default().extend(rows); + } + for rows in overrides.values_mut() { + // Deeper source rows apply first; outer rows win, with wire order preserved per source. + rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); + } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); + let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { all_props.entry(d).or_insert(val); } + let Some(def) = a.get("defID").and_then(guid) else { continue }; + if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + continue; + } + let var_value = a.get("varValue"); + if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") { + self.warn("malformed slot content assignment was skipped"); + continue; + } + let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid); + let Some(target) = target else { + self.warn("malformed slot content assignment was skipped"); + continue; + }; + if target == "4294967295:4294967295" { + self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); + continue; + } + if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) { + self.warn("slot content assignment target is missing or is not slot content"); + continue; + } + slot_contents.entry(def).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone()); - Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym)) + Some(( + Inst { + root, + path: own_path, + overrides: Rc::new(overrides), + props: Rc::new(all_props), + slot_contents: Rc::new(slot_contents), + slot_content: false, + symbols, + }, + sym, + )) } fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { @@ -1501,6 +2464,30 @@ fn own_text<'a>(v: &View<'a>) -> Option<&'a str> { v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty()) } +/// A generated connector child is identified by one exact template GUID, not its array position. +fn connector_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> { + v.get(field)?.get("overrides")?.list().iter().find(|row| { + row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| { + guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id)) + }) + }) +} + +fn connector_has_saved_label(v: &View<'_>) -> bool { + own_text(v).is_some() + || connector_record(v, "nodeGenerationData", 1) + .and_then(|r| r.get("textData")) + .and_then(|d| d.get("characters")) + .and_then(Kv::str) + .is_some_and(|s| !s.trim().is_empty()) +} + +fn valid_size(value: Option<&Kv<'_>>) -> Option<(f64, f64)> { + let x = num(value?, "x")?; + let y = num(value?, "y")?; + (x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0).then_some((x, y)) +} + /// The REST name of a stroke cap. fn cap_name(cap: &str) -> &str { match cap { @@ -1564,7 +2551,12 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) { } } -fn paint(p: &Kv) -> Option { +/// Whether a paint's colour or one of its gradient stops is bound to a variable. +fn paint_bound(p: &Kv) -> bool { + alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) +} + +fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); o.insert("type".into(), json!(ty)); @@ -1633,6 +2625,52 @@ fn paint(p: &Kv) -> Option { if let Some(r) = num(p, "rotation") { o.insert("rotation".into(), json!(r)); } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } } _ => {} } @@ -1662,6 +2700,32 @@ fn tracks(v: &View, key: &str) -> Vec { t.into_iter().map(|e| e.1).collect() } +/// Convert only unconstrained tracks, whose min and max sizing functions agree. +fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option { + let size = size?; + let min = size.get("minSizing")?; + let max = size.get("maxSizing")?; + let min_type = min.get("type").and_then(Kv::str)?; + let max_type = max.get("type").and_then(Kv::str)?; + if min_type != max_type { + return None; + } + match min_type { + "FIXED" => { + let px = num(min, "value")?; + let max_px = num(max, "value")?; + (px == max_px && px.is_finite() && (0.0..=MAX_GRID_TRACK_VALUE).contains(&px)).then(|| json!({"sizing": "fixed", "value": px})) + } + "FLEX" => { + let fr = num(min, "value")?; + let max_fr = num(max, "value")?; + (fr == max_fr && fr.is_finite() && fr > 0.0 && fr <= MAX_GRID_TRACK_VALUE).then(|| json!({"sizing": "fill", "value": fr})) + } + "HUG" => Some(json!({"sizing": "hug", "value": 0.0})), + _ => None, + } +} + /// The variable an `ALIAS` variable data points at. fn alias(d: Option<&Kv>) -> Option { let d = d?; @@ -1688,6 +2752,26 @@ fn variable_value(d: &Kv) -> Option { v.get("textValue").and_then(Kv::str).map(|s| json!(s)) } +/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST +/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. +fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) { + let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); + if ids.iter().all(|i| *i == 0) { + return; + } + let mut table = Obj::new(); + for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { + let Some(sid) = num(e, "styleID") else { continue }; + let mut s = text_style(&View::of(e), size); + if let Some(fills) = e.get("fillPaints") { + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); + } + table.insert((sid as u64).to_string(), Value::Object(s)); + } + o.insert("characterStyleOverrides".into(), json!(ids)); + o.insert("styleOverrideTable".into(), Value::Object(table)); +} + /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) @@ -1718,14 +2802,23 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "fontSize", "fontSize"); copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing"); copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); + insert_text_options(&mut s, v); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + s.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } + copy_str(&mut s, v, "leadingTrim", "leadingTrim"); let font_size = v.num("fontSize").or(size).unwrap_or(12.0); if let Some(ls) = v.get("letterSpacing") { let val = num(ls, "value").unwrap_or(0.0); let px = match ls.get("units").and_then(Kv::str) { Some("PIXELS") => val, - Some("PERCENT") => val / 100.0 * font_size, + Some("PERCENT") => { + // Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent. + s.insert("_letterSpacingPercent".into(), json!(val)); + val / 100.0 * font_size + } _ => val * font_size, }; s.insert("letterSpacing".into(), json!(px)); @@ -1753,7 +2846,8 @@ fn text_style(v: &View, size: Option) -> Obj { } } } - if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) { + // An empty URL is kept: on an override entry it clears the text's own link. + if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) { s.insert("hyperlink".into(), json!({"type": "URL", "url": u})); } s diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs new file mode 100644 index 00000000..ca47578d --- /dev/null +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -0,0 +1,146 @@ +//! Connector snapshot flattening stays within the node and depth budgets. + +use super::{MAX_DEPTH, Mapper, View}; +use crate::fig::kiwi::Kv; +use crate::rest::MAX_NODES; +use figcraft_doc::geom::Affine; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))); + obj(fields) +} + +fn message() -> Kv<'static> { + let line = record( + 0, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])), + ("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])), + ], + ); + let text = record( + 1, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])), + ], + ); + let background = record( + 2, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])), + ], + ); + let line_style = record(0, vec![]); + let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]); + let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]); + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("budget connector".into())), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", Kv::Float(1.0)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])), + ])]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +#[test] +fn connector_snapshot_respects_last_and_zero_child_slots() { + let message = message(); + let node = message.get("nodeChanges").unwrap().list().first().unwrap(); + let view = View::of(node); + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.count = MAX_NODES - 1; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); + assert_eq!(children[0]["type"], "VECTOR"); + assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); + assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.count = MAX_NODES; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert!(children.is_empty()); + assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); + assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); +} + +#[test] +fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { + let message = message(); + let mut mapper = Mapper::new(&message, MAX_NODES); + let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + assert_eq!(depth_limited["type"], "VECTOR"); + assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(depth_limited.get("children").is_none()); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.count = MAX_NODES - 1; + let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); + assert_eq!(budget_limited["type"], "VECTOR"); + assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(budget_limited.get("children").is_none()); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} diff --git a/crates/format/src/fig/map_image_filter_tests.rs b/crates/format/src/fig/map_image_filter_tests.rs new file mode 100644 index 00000000..a8db39a7 --- /dev/null +++ b/crates/format/src/fig/map_image_filter_tests.rs @@ -0,0 +1,27 @@ +//! Malformed image paint filters are skipped with a warning. + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..fc9293d9 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -6,12 +6,17 @@ //! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file //! carries its own schema, so the message is decoded generically and read by field name: a list //! of node changes (each with its id, parent id and position among its siblings) that make up the -//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`] +//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes +//! of images that travel inside the message, [`embedded_images`]). [`map`] //! turns that into the REST API file JSON shape and the REST importer builds the document, so both //! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors, //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped //! with a warning; malformed input never panics. +#[cfg(test)] +mod auto_layout_sizing_tests; +#[cfg(test)] +mod connector_label_tests; mod export; #[cfg(test)] mod export_tests; @@ -21,8 +26,22 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod slot_tests; +#[cfg(test)] +mod source_identity_tests; +#[cfg(test)] mod tests; +#[cfg(test)] +mod text_paragraph_tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; +#[cfg(test)] +mod text_skip_ink_tests; +#[cfg(test)] +mod text_style_tests; +use std::collections::BTreeMap; use std::io::Read; use figcraft_doc::Document; @@ -30,7 +49,7 @@ use figcraft_doc::Document; pub use export::export; use crate::FormatError; -use kiwi::Schema; +use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; @@ -39,6 +58,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -57,14 +88,24 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); - let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + // The message goes before the layers are built, so the images it carries are copied out first. + let embedded: BTreeMap> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect(); + drop(msg); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); + let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } @@ -94,6 +135,41 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!(".fig file: {}", msg.into())) } +/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's +/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed +/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The +/// bytes stay borrowed from the message. +fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut found = BTreeMap::new(); + let mut pending = vec![msg]; + while let Some(value) = pending.pop() { + match value { + Kv::Object(fields) => { + if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { + found.entry(hash).or_insert(bytes); + } + pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); + } + Kv::Array(items) => pending.extend(items), + _ => {} + } + } + found +} + +/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> { + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; + let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; + let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?; + if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE { + return None; + } + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes)) +} + /// The parts of a `.fig` archive the import reads. struct Archive<'b> { canvas: Vec, diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 112a767a..ccb5c291 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -76,8 +77,8 @@ pub(super) fn decode(b: &[u8]) -> Option { net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); } for _ in 0..ns { - r.u32()?; - net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + let style = r.u32()?; + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -110,7 +111,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/slot_tests.rs b/crates/format/src/fig/slot_tests.rs new file mode 100644 index 00000000..17c75464 --- /dev/null +++ b/crates/format/src/fig/slot_tests.rs @@ -0,0 +1,225 @@ +//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and +//! the content subtrees that instances assign, on synthetic messages. + +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn node(local: u32, kind: &'static str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))]))); + } + all.append(&mut fields); + o(all) +} + +fn mapped(msg: &Kv<'_>) -> Value { + to_rest(msg, "Synthetic resources", crate::rest::MAX_NODES).0 +} + +fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id)) +} + +fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec> { + let Kv::Object(fields) = msg else { panic!() }; + let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() }; + rows +} + +fn slot_message(content: Option, preferred: bool) -> Kv<'static> { + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))]; + if let Some(content) = content { + instance_fields.push(( + "componentPropAssignments", + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]), + )); + } + let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))]; + if preferred { + definition.push(( + "preferredValues", + o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]), + )); + } + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", None, vec![]), + node(2, "CANVAS", Some(1), vec![]), + node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]), + node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]), + node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + node(13, "RECTANGLE", Some(12), vec![]), + node(20, "INSTANCE", Some(2), instance_fields), + node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + node(31, "RECTANGLE", Some(30), vec![]), + node(32, "ELLIPSE", Some(30), vec![]), + node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + ]), + )]) +} +#[test] +fn slot_definition_reference_and_arbitrary_override_children_are_imported() { + let rest = mapped(&slot_message(Some(30), false)); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + let main_slot = named_id(&rest["document"], "9:12").unwrap(); + assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert!(slot.get("componentPropertyReferences").is_none()); + assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); + let kids = slot["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "I9:20;9:31"); + assert_eq!(kids[1]["id"], "I9:20;9:32"); + + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let main = doc + .node(*doc.pages.first().unwrap()) + .unwrap() + .children + .iter() + .find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }))) + .unwrap(); + assert!( + matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty())) + ); + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(instance.children[0]).unwrap(); + assert!(!slot.prop_refs.contains_key("slot")); + assert_eq!(slot.source, Some(main.children[0])); + assert!(slot.overrides.contains("slot")); + assert_eq!(slot.children.len(), 2); + assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none()))); + + let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap(); + let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let public_slot = public.node(public_instance.children[0]).unwrap(); + assert!(!public_slot.overrides.contains("slot")); +} +#[test] +fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() { + let mut msg = slot_message(Some(30), false); + let rows = changes(&mut msg); + let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap(); + let Kv::Object(fields) = slot_frame else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME"); + rows.extend([ + node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![]), + node(42, "RECTANGLE", Some(41), vec![]), + ]); + + let rest = mapped(&msg); + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let outer = doc + .ids() + .filter_map(|id| doc.node(id)) + .find(|n| { + matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }) + && !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. }))) + }) + .unwrap(); + let slot = doc.node(outer.children[0]).unwrap(); + let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap(); + assert!(own_frame.source.is_none()); + + let nested = slot + .children + .iter() + .filter_map(|id| doc.node(*id)) + .flat_map(|n| n.children.iter()) + .filter_map(|id| doc.node(*id)) + .find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })) + .unwrap(); + let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") }; + assert_eq!(nested.source, Some(component)); + let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap(); + let expanded_frame = doc.node(nested.children[0]).unwrap(); + assert_eq!(expanded_frame.source, Some(main_frame.id)); + assert_eq!(expanded_frame.children.len(), 1); + assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0])); +} +#[test] +fn outer_slot_assignment_wins_over_a_nested_instances_own() { + let mut msg = slot_message(Some(30), false); + let assignment = |content: u32| { + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]) + }; + // The nested instance inside the main assigns the empty slot content; the outer instance's content wins. + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + changes(&mut msg).extend([ + node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + ]); + let rest = mapped(&msg); + let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap(); + assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}"); +} +#[test] +fn unassigned_slot_keeps_default_and_bad_content_target_warns() { + let rest = mapped(&slot_message(None, false)); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + assert!(slot.get("overrides").is_none()); + + let (rest, warnings, _) = to_rest(&slot_message(Some(999), false), "Synthetic", crate::rest::MAX_NODES); + assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + + let (rest, warnings, _) = to_rest(&slot_message(Some(33), true), "Synthetic", crate::rest::MAX_NODES); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}"); + let empty = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); +} diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs new file mode 100644 index 00000000..927b0227 --- /dev/null +++ b/crates/format/src/fig/source_identity_tests.rs @@ -0,0 +1,1120 @@ +//! Synthetic .fig source identity: cached library components, contextual sources, main switches +//! and invalid provenance. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, NodeId, NodeKind, PropDef}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn s(value: &str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn matrix(x: f32) -> Kv<'static> { + o(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(x)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]) +} + +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", s(name))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s(&format!("{id:04}")))]))); + } + fields.extend(more); + o(fields) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]) +} + +fn row(path: &[u64], more: Fields) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", Kv::Array(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +fn solid(r: f32, g: f32, b: f32) -> Kv<'static> { + o(vec![ + ("type", Kv::Enum("SOLID")), + ("color", o(vec![("r", Kv::Float(r)), ("g", Kv::Float(g)), ("b", Kv::Float(b)), ("a", Kv::Float(1.0))])), + ("opacity", Kv::Float(1.0)), + ("visible", Kv::Bool(true)), + ]) +} + +fn schema() -> Schema { + Schema::parse(export::SCHEMA).unwrap() +} + +fn cached_component_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Visible page", vec![]), + node( + 3, + Some(1), + "CANVAS", + "Remote library", + vec![ + ("internalOnly", Kv::Bool(true)), + ( + "variableModeBySetMap", + o(vec![("entries", Kv::Array(vec![o(vec![("variableSetID", o(vec![("guid", g(30))])), ("variableModeID", g(52))])]))]), + ), + ], + ), + node( + 30, + Some(3), + "VARIABLE_SET", + "Remote theme", + vec![( + "variableSetModes", + Kv::Array(vec![ + o(vec![("id", g(51)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(52)), ("name", s("Dark")), ("sortPosition", s("\""))]), + ]), + )], + ), + node( + 9, + Some(3), + "FRAME", + "Cached component set", + vec![("isStateGroup", Kv::Bool(true)), ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 10, + Some(9), + "SYMBOL", + "Cached remote icon", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 11, + Some(10), + "ROUNDED_RECTANGLE", + "Cached path", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 12, + Some(10), + "INSTANCE", + "Nested cached instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(22, vec![row(&[23], vec![("fillPaints", Kv::Array(vec![solid(0.2, 0.7, 0.1)]))])])), + ], + ), + node( + 13, + Some(9), + "SYMBOL", + "Nested cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 14, + Some(13), + "ROUNDED_RECTANGLE", + "Nested cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 15, + Some(10), + "INSTANCE", + "Nested internal instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(13, vec![])), + ], + ), + node( + 20, + Some(2), + "INSTANCE", + "Visible icon instance", + vec![ + ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(10, vec![])), + ], + ), + node( + 22, + Some(2), + "SYMBOL", + "Visible nested main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 23, + Some(22), + "ROUNDED_RECTANGLE", + "Visible nested path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]), + )]) +} + +fn cached_component_fixture() -> Vec { + let schema = schema(); + super::tests::canvas(&schema, &cached_component_message()) +} + +fn cached_component_override_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let instance = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible icon instance")).unwrap(); + let Kv::Object(fields) = instance else { panic!("cached component fixture has an unexpected wire shape") }; + fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 = + symbol(10, vec![row(&[12], vec![("symbolData", symbol(22, vec![row(&[26], vec![("overriddenSymbolID", g(24))])]))])]); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Overridden cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Overridden cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 26, + Some(22), + "INSTANCE", + "Deep cached instance", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0)), ("symbolData", symbol(27, vec![]))], + ), + node( + 27, + Some(3), + "SYMBOL", + "Base deep main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 28, + Some(27), + "ROUNDED_RECTANGLE", + "Base deep path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn cached_component_default_swap_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let owner = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible nested main")).unwrap(); + let Kv::Object(fields) = owner else { panic!("cached component fixture has an unexpected wire shape") }; + fields.push(( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(90)), + ("name", s("Icon")), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(24))])), + ])]), + )); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Default cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Default cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn assert_default_swap(doc: &Document) { + let owner = named_node(doc, "Visible nested main"); + let target = named_node(doc, "Default cached main"); + let NodeKind::Component { props, .. } = &doc.node(owner).unwrap().kind else { panic!("visible component lost its property definition") }; + assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: target, preferred: Vec::new() }), "{props:?}"); + assert!(doc.is_remote(target)); +} + +fn named_node(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|node| node.name == name).unwrap().id +} + +fn assert_dark_remote_mode(doc: &Document, node: NodeId) { + let collection = doc.variables.collections.iter().find(|collection| collection.name == "Remote theme").unwrap(); + let dark = collection.modes.iter().find(|mode| mode.name == "Dark").unwrap(); + assert_eq!(doc.modes_for(node).get(&collection.id), Some(&dark.id)); +} + +fn assert_no_linked_library_records(doc: &Document) { + assert!(doc.libraries.enabled.is_empty()); + assert!(doc.libraries.components.is_empty()); + assert!(doc.libraries.styles.is_empty()); + assert!(doc.libraries.collections.is_empty()); + assert!(doc.libraries.variables.is_empty()); +} + +#[test] +fn hidden_cached_component_is_retained_and_linked() { + let (doc, warnings) = import(&cached_component_fixture(), "Cached library.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.pages.len(), 1, "hidden cache must not become a visible page"); + let check = |doc: &Document| { + let store = doc.library_store().expect("private .fig cache is retained"); + let component = named_node(doc, "Cached remote icon"); + let path = doc.node(component).unwrap().children[0]; + let set = doc.node(component).unwrap().parent.unwrap(); + assert!(matches!(doc.node(set).unwrap().kind, NodeKind::ComponentSet { .. })); + assert_eq!(doc.node(doc.node(set).unwrap().parent.unwrap()).unwrap().parent, Some(store)); + assert!(doc.is_remote(component) && doc.is_remote(path)); + assert!(matches!(doc.node(component).unwrap().kind, NodeKind::Component { .. })); + assert_dark_remote_mode(doc, component); + assert_no_linked_library_records(doc); + // The visible instance resolves to the cached main, and its layer to the main's layer. + let instance = named_node(doc, "Visible icon instance"); + assert!(matches!(doc.node(instance).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == component)); + assert_eq!(doc.node(doc.node(instance).unwrap().children[0]).unwrap().source, Some(path)); + // A cached main nested in another cached main is kept too, and so is an instance in the + // cache of a visible main, with the override its row carries. + let nested_main = named_node(doc, "Nested cached main"); + let nested = named_node(doc, "Nested internal instance"); + assert!(doc.is_remote(nested_main)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == nested_main)); + let external_main = named_node(doc, "Visible nested main"); + let external = doc + .node(component) + .unwrap() + .children + .iter() + .copied() + .find(|id| doc.node(*id).is_some_and(|n| n.name == "Nested cached instance")) + .unwrap(); + assert!(matches!(doc.node(external).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == external_main)); + let external_child = doc.node(external).unwrap().children[0]; + assert_eq!(doc.node(external_child).unwrap().source, Some(doc.node(external_main).unwrap().children[0])); + assert_eq!(fill_rgb_node(doc, external_child), Some([f64::from(0.2_f32), f64::from(0.7_f32), f64::from(0.1_f32)])); + }; + check(&doc); + let (saved, warnings) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + check(&saved); +} + +#[test] +fn visible_instance_override_retains_and_links_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_override_fixture(), "Cached override.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let nested = named_node(&doc, "Nested cached instance"); + let deep = named_node(&doc, "Deep cached instance"); + assert_eq!(doc.node(deep).unwrap().parent, Some(nested)); + let NodeKind::Instance { component: overridden, .. } = doc.node(deep).unwrap().kind else { panic!("nested-row override lost its instance") }; + let overridden = doc.node(overridden).unwrap(); + assert_eq!(overridden.name, "Overridden cached main"); + assert!(doc.is_remote(overridden.id)); + assert_eq!(doc.node(doc.node(deep).unwrap().children[0]).unwrap().source, Some(overridden.children[0])); +} + +#[test] +fn visible_component_default_swap_retains_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_default_swap_fixture(), "Cached default swap.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_default_swap(&doc); + let (saved, _) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert_default_swap(&saved); +} + +#[test] +fn public_rest_import_ignores_private_fig_provenance() { + let value = json!({ + "name": "Public REST", + "document": {"type": "DOCUMENT", "children": [{"type": "CANVAS", "name": "Page", "children": []}]}, + "_figLibraryContexts": [{"type": "FRAME", "name": "Private cache", "children": [{"type": "COMPONENT", "name": "Must not import", "children": []}]}] + }); + let (doc, warnings) = crate::rest::import(&value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import")); + // Valid provenance on mapped layers links nothing either. + for message in [message(false), external_namespace_message()] { + let (json, _, _) = super::map::to_rest(&message, "Identity", crate::rest::MAX_NODES); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + } +} + +#[test] +fn the_last_change_of_a_guid_wins_and_removed_changes_delete() { + let moved = node(12, Some(11), "FRAME", "Moved frame", vec![]); + let mut removed = node(13, Some(10), "FRAME", "Removed frame", vec![]); + let Kv::Object(fields) = &mut removed else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "phase").unwrap().1 = Kv::Enum("REMOVED"); + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "FRAME", "Left", vec![]), + node(11, Some(2), "FRAME", "Right", vec![]), + node(12, Some(10), "FRAME", "Moved frame", vec![]), + node(13, Some(10), "FRAME", "Removed frame", vec![]), + moved, + removed, + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Changes.fig").unwrap(); + assert_eq!(doc.node(named_node(&doc, "Moved frame")).unwrap().parent, Some(named_node(&doc, "Right"))); + assert!(doc.nodes().all(|n| n.name != "Removed frame")); + assert!(doc.node(named_node(&doc, "Left")).unwrap().children.is_empty()); +} + +#[test] +fn internal_library_parent_cycles_are_bounded() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Visible cyclic main", vec![("symbolData", symbol(11, vec![]))]), + node(3, Some(9), "CANVAS", "Cyclic library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(10, Some(3), "SYMBOL", "Unrelated component", vec![]), + node(11, Some(3), "SYMBOL", "Loop component", vec![]), + ]), + )]); + let (doc, warnings) = import(&super::tests::canvas(&schema(), &message), "Cyclic library.fig").unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("deeper than")), "{warnings:?}"); + assert!(doc.node_count() < 1000, "the loop is cut at the nesting limit"); +} + +#[test] +fn parent_loops_through_the_internal_canvas_end() { + // The canvas is a child of a frame inside one of its own symbols, and the used main sits below + // that frame: the walk up from the main reaches the canvas again. + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Uses the main", vec![("symbolData", symbol(12, vec![]))]), + node(3, Some(9), "CANVAS", "Loop library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(11, Some(3), "SYMBOL", "Loop symbol", vec![]), + node(12, Some(9), "SYMBOL", "Used main", vec![]), + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Loop.fig").unwrap(); + assert!(doc.nodes().any(|n| n.name == "Used main")); +} + +fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { + let node = doc.node(id)?; + match &node.fills.first()?.kind { + PaintKind::Solid { color } => Some([f64::from(color.r), f64::from(color.g), f64::from(color.b)]), + _ => None, + } +} + +fn alias_schema() -> Schema { + let schema = export::SCHEMA.replace("message NodeChange GUID guid=1,", "message NodeChange GUID guid=1, GUID overrideKey=500,"); + Schema::parse(&schema).unwrap() +} + +fn alias(id: u64) -> Fields { + vec![("overrideKey", g(id))] +} + +fn import_alias_message(message: Kv<'static>) -> Document { + let schema = alias_schema(); + let bytes = super::tests::canvas(&schema, &message); + let (doc, warnings) = import(&bytes, "Alias.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn fill_rgb(doc: &Document, name: &str) -> Option<[f64; 3]> { + let node = doc.nodes().find(|n| n.name == name)?; + fill_rgb_node(doc, node.id) +} + +fn alias_priority_message() -> Kv<'static> { + let nested_local = symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))])]); + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Inner main", vec![]), + node(11, Some(10), "FRAME", "Normal frame", vec![]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Alias leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Outer main", vec![]), + node( + 21, + Some(20), + "INSTANCE", + "Nested slot", + vec![ + ("overrideKey", g(221)), + ("symbolData", nested_local), + ("derivedSymbolData", Kv::Array(vec![row(&[112], vec![("opacity", Kv::Float(0.25))])])), + ], + ), + node( + 30, + Some(2), + "INSTANCE", + "Alias copy", + vec![ + ( + "symbolData", + symbol( + 20, + vec![ + row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 1.0, 0.0)]))]), + row(&[21, 12], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + ], + ), + ), + ("derivedSymbolData", Kv::Array(vec![row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.5, 0.0)]))])])), + ], + ), + ]), + )]) +} + +#[test] +fn override_aliases_normalize_lazily_and_keep_outer_wire_order() { + let doc = import_alias_message(alias_priority_message()); + let main = named(&doc, "Inner main"); + let leaf = child(&doc, child(&doc, main, 0), 0); + let outer_main = named(&doc, "Outer main"); + let main_slot = child(&doc, outer_main, 0); + let main_leaf = child(&doc, child(&doc, main_slot, 0), 0); + let copy = named(&doc, "Alias copy"); + let nested = child(&doc, copy, 0); + let frame = child(&doc, nested, 0); + let expanded_leaf = child(&doc, frame, 0); + assert_eq!(doc.node(main_leaf).unwrap().source, Some(leaf)); + assert_eq!(doc.node(expanded_leaf).unwrap().source, Some(main_leaf)); + assert_eq!(fill_rgb_node(&doc, expanded_leaf), Some([1.0, 0.5, 0.0])); + assert_eq!(doc.node(expanded_leaf).unwrap().opacity, 0.25); + assert_eq!(fill_rgb_node(&doc, leaf), None, "source node stays unchanged"); +} + +#[test] +fn selected_swapped_main_owns_alias_resolution_scope() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Old main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "Old leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Swapped main", vec![]), + node(21, Some(20), "ROUNDED_RECTANGLE", "Swapped leaf", alias(112)), + node( + 30, + Some(2), + "INSTANCE", + "Swap", + vec![ + ("overriddenSymbolID", g(20)), + ("symbolData", symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))])])), + ], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Swap"); + let source = named(&doc, "Swapped leaf"); + let expanded = child(&doc, copy, 0); + assert_eq!(doc.node(expanded).unwrap().source, Some(source)); + assert_eq!(fill_rgb_node(&doc, expanded), Some([1.0, 0.0, 0.0])); + assert_eq!(fill_rgb(&doc, "Old leaf"), None); +} + +#[test] +fn ambiguous_alias_is_ignored_but_current_guid_still_matches() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "First", alias(13)), + node(12, Some(10), "ROUNDED_RECTANGLE", "Second", alias(112)), + node(13, Some(10), "ROUNDED_RECTANGLE", "Current GUID", alias(112)), + node( + 20, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 10, + vec![ + row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + row(&[13], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))]), + ], + ), + )], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Copy"); + let first = child(&doc, copy, 0); + let second = child(&doc, copy, 1); + let current = child(&doc, copy, 2); + assert_eq!(doc.node(first).unwrap().source, Some(named(&doc, "First"))); + assert_eq!(fill_rgb_node(&doc, first), None, "ambiguous alias must not be guessed"); + assert_eq!(doc.node(second).unwrap().source, Some(named(&doc, "Second"))); + assert_eq!(fill_rgb_node(&doc, second), None, "ambiguous alias must not match either target"); + assert_eq!(doc.node(current).unwrap().source, Some(named(&doc, "Current GUID"))); + assert_eq!(fill_rgb_node(&doc, current), Some([0.0, 0.0, 1.0]), "a current GUID outranks an alias with the same key"); +} + +fn message(swapped: bool) -> Kv<'static> { + let shape = |id, parent, x| node(id, Some(parent), "ROUNDED_RECTANGLE", "Same layer", vec![("transform", matrix(x))]); + let mut changes = vec![ + node(1, None, "DOCUMENT", "Identity", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Renamed Left main", vec![]), + node(11, Some(10), "FRAME", "Frame", vec![("transform", matrix(2.0))]), + shape(12, 11, 2.0), + shape(13, 11, 4.0), + shape(14, 11, 6.0), + node(15, Some(11), "FRAME", "Deep frame", vec![]), + node(16, Some(15), "FRAME", "Deeper frame", vec![]), + shape(17, 16, 8.0), + node( + 20, + Some(2), + "SYMBOL", + "State=Right, Renamed=Yes", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(21, Some(20), "FRAME", "Frame", vec![("transform", matrix(if swapped { 23.0 } else { 2.0 }))]), + shape(22, 21, 2.0), + shape(23, 21, 4.0), + shape(24, 21, 6.0), + node(30, Some(2), "SYMBOL", "Outer", vec![]), + node(40, Some(30), "INSTANCE", "Original wrapper", vec![("symbolData", symbol(50, vec![row(&[60, 21], vec![("transform", matrix(3.0))])]))]), + node(50, Some(2), "SYMBOL", "Renamed wrapper main", vec![]), + node( + 60, + Some(50), + "INSTANCE", + "Same nested slot", + vec![ + ("symbolData", symbol(20, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(61, Some(50), "INSTANCE", "Same nested slot", vec![("symbolData", symbol(20, vec![]))]), + ]; + let mut nested = vec![ + ("name", s("Renamed nested slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ]; + if swapped { + nested.push(("overriddenSymbolID", g(10))); + } + changes.push(node( + 70, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row(&[40], vec![("name", s("Renamed wrapper"))]), + row(&[40, 60], nested), + row(&[40, 60, if swapped { 11 } else { 21 }], vec![("transform", matrix(2.0))]), + ], + ), + )], + )); + o(vec![("nodeChanges", Kv::Array(changes))]) +} + +fn import_message(swapped: bool) -> Document { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &message(swapped)); + let (doc, notes) = import(&bytes, "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + doc +} + +fn named(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name).unwrap().id +} + +fn child(doc: &Document, id: NodeId, index: usize) -> NodeId { + doc.node(id).unwrap().children[index] +} + +fn assert_contextual(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let wrapper_main = named(doc, "Renamed wrapper main"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert!(matches!(&doc.node(copy_wrapper).unwrap().kind, NodeKind::Instance {component, ..} if *component == wrapper_main)); + for index in 0..2 { + let default_slot = child(doc, main_wrapper, index); + let copy_slot = child(doc, copy_wrapper, index); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot), "slot {index}"); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + } + let default_slot = child(doc, main_wrapper, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let inherited = child(doc, default_slot, 0); + let copy_frame = child(doc, copy_slot, 0); + assert!(matches!(doc.node(copy_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(copy_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(copy_frame).unwrap().source, Some(inherited)); + for index in 0..3 { + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().source, Some(child(doc, inherited, index))); + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let a = doc.node(default_slot).unwrap(); + let b = doc.node(copy_slot).unwrap(); + assert!(!a.visible && a.is_mask && a.opacity < 1.0); + assert!(b.visible && !b.is_mask && b.opacity == 1.0); + assert!(matches!(&a.kind, NodeKind::Instance {props, ..} if props.len() == 1 && props.values().all(|p| p == &json!(false)))); + assert!(matches!(&b.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn contextual_sources_preserve_inherited_geometry_and_explicit_clears() { + assert_contextual(&import_message(false)); +} + +fn assert_swapped(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let default_slot = child(doc, main_wrapper, 0); + let left = named(doc, "Renamed Left main"); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, props} if *component == left && props.is_empty())); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + let current_frame = child(doc, copy_slot, 0); + let left_frame = child(doc, left, 0); + assert!(matches!(doc.node(current_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current_frame).unwrap().source, Some(left_frame)); + assert_eq!(doc.node(current_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, right, 0)).unwrap().transform.translation().x, 23.0); + for index in 0..3 { + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().source, Some(child(doc, left_frame, index))); + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let deep_copy = child(doc, child(doc, child(doc, current_frame, 3), 0), 0); + let deep_main = child(doc, child(doc, child(doc, left_frame, 3), 0), 0); + assert_eq!(doc.node(deep_copy).unwrap().source, Some(deep_main)); + assert_eq!(doc.node(deep_copy).unwrap().transform.translation().x, 8.0); + let other = child(doc, copy_wrapper, 1); + assert_eq!(doc.node(other).unwrap().source, Some(child(doc, main_wrapper, 1))); + assert!(matches!(&doc.node(other).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(doc.node(copy_slot).unwrap().visible && !doc.node(copy_slot).unwrap().is_mask && doc.node(copy_slot).unwrap().opacity == 1.0); +} + +#[test] +fn swapped_children_use_current_main_through_frames_and_multiple_siblings() { + assert_swapped(&import_message(true)); +} + +fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) { + if value.get("id").and_then(Value::as_str) == Some(id) { + edit(value); + } + if let Some(kids) = value.get_mut("children").and_then(Value::as_array_mut) { + for child in kids { + edit_node(child, id, edit); + } + } +} + +#[test] +fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { + for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] { + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); + edit_node(&mut json["document"]["children"][0], id, &mut |node| { + node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin); + if id == "1:70" { + node["componentId"] = json!("1:70"); // a root cannot be its own component + } else if origin == "1:40" { + node["id"] = json!("I1:999;1:40"); + } else { + node["id"] = json!(format!("I1:70;{origin}")); // valid namespace, but missing/foreign target + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let target = if id == "1:70" { named(&doc, "Copy") } else { named(&doc, "Renamed wrapper") }; + assert!(doc.node(target).unwrap().source.is_none(), "{origin}/{id}"); + } +} + +#[test] +fn forward_main_references_and_duplicate_ids_are_conservative() { + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); + json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some()); + // 1:40 is nested inside Outer, so duplicate that actual template at page level. + let template = json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + json["document"]["children"][0]["children"].as_array_mut().unwrap().push(template); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Renamed wrapper")).unwrap().source.is_none()); +} + +#[test] +fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() { + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); + edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| { + node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60"); + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let copy_slot = child(&doc, child(&doc, named(&doc, "Copy"), 0), 0); + assert!(doc.node(copy_slot).unwrap().source.is_none()); + assert!(doc.nodes().any(|n| n.name == "Same nested slot"), "the raw slot still exists"); +} + +#[test] +fn instance_to_empty_frame_keeps_its_source_identity() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[40, 61], vec![("type", Kv::Enum("FRAME"))])); + let (doc, _) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + let main = child(&doc, named(&doc, "Outer"), 0); + let copy = child(&doc, named(&doc, "Copy"), 0); + let frame = child(&doc, copy, 1); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert!(doc.node(frame).unwrap().children.is_empty()); + assert_eq!(doc.node(frame).unwrap().source, Some(child(&doc, main, 1))); +} + +#[test] +fn explicit_frame_to_instance_resets_children_to_the_resolved_main() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + nodes.push(node(80, Some(30), "FRAME", "Original frame", vec![])); + nodes.push(node(81, Some(80), "ROUNDED_RECTANGLE", "Old frame child", vec![])); + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[80], vec![("type", Kv::Enum("INSTANCE")), ("overriddenSymbolID", g(10))])); + let (doc, notes) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let original = child(&doc, named(&doc, "Outer"), 1); + let current = child(&doc, named(&doc, "Copy"), 1); + let left = named(&doc, "Renamed Left main"); + assert!(matches!(doc.node(original).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current).unwrap().source, Some(original)); + assert!(matches!(&doc.node(current).unwrap().kind, NodeKind::Instance {component, ..} if *component == left)); + assert_eq!(doc.node(child(&doc, current, 0)).unwrap().source, Some(child(&doc, left, 0))); +} + +// The orphan symbol is decoded and expanded but is not a layer on any imported page. This +// matches an external main: its instance becomes a frame while the rendered body survives. +fn external_namespace_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "External namespace", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node( + 10, + Some(2), + "SYMBOL", + "Known main", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(11, Some(10), "FRAME", "Known frame", vec![("transform", matrix(2.0))]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Known leaf", vec![("transform", matrix(4.0))]), + node(30, Some(2), "SYMBOL", "Outer external", vec![]), + node( + 90, + Some(30), + "INSTANCE", + "External body", + vec![("symbolData", symbol(99, vec![row(&[110, 11], vec![("transform", matrix(3.0))])]))], + ), + node(99, None, "SYMBOL", "External orphan", vec![]), + node(100, Some(99), "FRAME", "Body frame", vec![]), + node(101, Some(99), "FRAME", "Other body frame", vec![]), + node( + 110, + Some(100), + "INSTANCE", + "Same slot", + vec![ + ("symbolData", symbol(10, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(111, Some(100), "INSTANCE", "Same slot", vec![("symbolData", symbol(10, vec![]))]), + node( + 70, + Some(2), + "INSTANCE", + "External copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row( + &[90, 110], + vec![ + ("name", s("Renamed owned slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ], + ), + row(&[90, 110, 11], vec![("transform", matrix(2.0))]), + ], + ), + )], + ), + ]), + )]) +} + +fn assert_external_namespace(doc: &Document) { + let outer = named(doc, "Outer external"); + let copy = named(doc, "External copy"); + let main = named(doc, "Known main"); + let body = child(doc, outer, 0); + let copied_body = child(doc, copy, 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + assert!(matches!(doc.node(copied_body).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(body).unwrap().source, None, "the absent external main must not acquire a guessed native node"); + assert_eq!(doc.node(copied_body).unwrap().source, Some(body)); + let frame = child(doc, body, 0); + let copied_frame = child(doc, copied_body, 0); + assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); + assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + for index in 0..2 { + let slot = child(doc, frame, index); + let copied_slot = child(doc, copied_frame, index); + assert_eq!(doc.node(slot).unwrap().source, Some(main), "known owned instance has its real root main"); + assert_eq!(doc.node(copied_slot).unwrap().source, Some(slot), "copied slots preserve the contextual owned layer identity"); + assert!(matches!(&doc.node(slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + assert!(matches!(&doc.node(copied_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + let inherited = child(doc, slot, 0); + let current = child(doc, copied_slot, 0); + assert_eq!(doc.node(inherited).unwrap().source, Some(child(doc, main, 0))); + assert_eq!(doc.node(current).unwrap().source, Some(inherited)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, if index == 0 { 3.0 } else { 2.0 }); + assert_eq!(doc.node(current).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); + } + let old = doc.node(child(doc, frame, 0)).unwrap(); + let current = doc.node(child(doc, copied_frame, 0)).unwrap(); + assert!(!old.visible && old.is_mask && old.opacity < 1.0); + assert!(current.visible && !current.is_mask && current.opacity == 1.0); + assert_eq!(current.name, "Renamed owned slot"); + assert!(matches!(&old.kind, NodeKind::Instance {props, ..} if props.len() == 1)); + assert!(matches!(¤t.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() { + let (doc, notes) = import(&super::tests::canvas(&schema(), &external_namespace_message()), "External.fig").unwrap(); + assert!(notes.iter().any(|w| w.contains("components that aren't in the file")), "{notes:?}"); + assert!(!notes.iter().any(|w| w.contains("source link")), "materialized own layers are not unresolved copies: {notes:?}"); + assert_external_namespace(&doc); +} + +#[test] +fn external_namespace_is_private_and_requires_actual_wire_instances() { + for mode in 0..3 { + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); + if mode == 0 { + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + continue; + } + edit_node(&mut json["document"]["children"][0], "1:90", &mut |node| { + if mode == 1 { + node["type"] = json!("FRAME"); + } else { + node[crate::rest::FIG_SOURCE_FIELD]["wireInstance"] = json!(false); + node[crate::rest::FIG_SOURCE_FIELD]["wireFrame"] = json!(true); + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let raw_slot = child(&doc, child(&doc, child(&doc, named(&doc, "Outer external"), 0), 0), 0); + assert!(doc.node(raw_slot).unwrap().source.is_none(), "ordinary/fake frame mode {mode} must not establish a namespace"); + } +} + +#[test] +fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { + for mode in 0..3 { + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":true,"wireInstance":false}})), + 1 => edit_node(page, "1:90", &mut |node| { + node["componentId"] = json!("1:30"); + }), + _ => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"COMPONENT", "name":"Cycle main", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":false,"wireInstance":false}, + "children":[{"id":"1:199", "type":"INSTANCE", "name":"Cycle edge", "componentId":"1:30", + "_figSource":{"origin":"1:199","expanded":false,"wireFrame":false,"wireInstance":true}}]})), + } + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let body = child(&doc, named(&doc, "Outer external"), 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + let slot = child(&doc, child(&doc, body, 0), 0); + assert!(doc.node(slot).unwrap().source.is_none(), "existing invalid/cycle/self main mode {mode} must not become a materialized namespace"); + } +} + +#[test] +fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { + for mode in 0..4 { + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => { + // Same root namespace and GUID, but physically under a different frame slot. + let mut moved = None; + edit_node(page, "I1:70;1:90;1:100", &mut |node| { + moved = Some(node["children"].as_array_mut().unwrap().remove(0)); + }); + edit_node(page, "I1:70;1:90;1:101", &mut |node| { + node["children"] = json!([moved.take().unwrap()]); + }); + } + 1 => edit_node(page, "I1:70;1:90;1:110", &mut |node| { + node["id"] = json!("I1:70;1:999;1:110"); + }), + 2 => { + let duplicate = page["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + page["children"].as_array_mut().unwrap().push(duplicate); + } + _ => edit_node(page, "I1:90;1:100", &mut |node| { + node["id"] = json!("I1:999;1:100"); + }), + } + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let slot = named(&doc, "Renamed owned slot"); + assert!(doc.node(slot).unwrap().source.is_none(), "invalid frame namespace mode {mode}"); + assert!(warnings.iter().any(|w| w.contains("source link")), "invalid expanded paths remain reported: {warnings:?}"); + } +} + +fn no_style() -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(4_294_967_295)), ("localID", Kv::Uint(4_294_967_295))]) +} + +#[test] +fn an_explicit_no_style_detaches_and_beats_the_older_style_field() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(5, Some(2), "RECTANGLE", "Red", vec![("styleType", Kv::Enum("FILL"))]), + node(6, Some(2), "RECTANGLE", "Box", vec![("styleIdForFill", o(vec![("guid", no_style())])), ("inheritFillStyleID", g(5))]), + node(7, Some(2), "RECTANGLE", "Linked", vec![("inheritFillStyleID", g(5))]), + ]), + )]); + let (json, _, _) = super::map::to_rest(&message, "Detach", crate::rest::MAX_NODES); + let find = |name: &str| json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == name).unwrap().clone(); + assert!(find("Box")["styles"].get("fill").is_none(), "{}", find("Box")["styles"]); + assert!(find("Linked")["styles"].get("fill").is_some(), "the older field still links when nothing detaches"); +} diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..abc90d73 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -3,13 +3,13 @@ //! fixtures built in code with the parent module's helpers. use figcraft_doc::proto::Action; -use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule}; +use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule}; use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex { /// A square with one curved side and a rounded, mirrored corner, filled even-odd. fn square_blob() -> Vec { network::encode(&Network { - vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)], + vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 0.0, 50.0)], segments: vec![ seg(0, 1, (10.0, 0.0), (-10.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0)), @@ -39,6 +39,15 @@ fn arrow_blob() -> Vec { }) } +/// An open path with one missing style ID and one unsupported point join. +fn join_fallback_blob() -> Vec { + network::encode(&Network { + vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)], + segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))], + regions: vec![], + }) +} + fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec>) -> Kv<'static> { o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))]) } @@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> { ("transform", at(700.0, 0.0)), ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), ("fillGeometry", outline(0)), + ("strokeJoin", e("MITER")), + ("dashPattern", arr(vec![f(4.0), f(2.0)])), ( "vectorData", vector_data( 1, 100.0, 50.0, - vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])], + vec![ + o(vec![ + ("styleID", u(1)), + ("cornerRadius", f(4.0)), + ("handleMirroring", e("ANGLE_AND_LENGTH")), + ("strokeJoin", e("BEVEL")), + ]), + o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]), + o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]), + ], ), ), ], @@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> { ("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])), ], ), + node( + 42, + Some((1, "#")), + "VECTOR", + "Join Fallbacks", + vec![ + ("size", xy(100.0, 10.0)), + ("transform", at(0.0, 330.0)), + stroke(), + ("strokeJoin", e("MITER")), + ("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])), + ], + ), node( 32, Some((1, "#")), @@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> { ]; let mut broken = square_blob(); broken.truncate(30); - let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken]; + let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()]; o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))]) } @@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() { assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength))); assert_eq!(wave.vertices[0].mirror, None); + assert_eq!( + wave.vertices.iter().map(|v| v.join).collect::>(), + [None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)] + ); + assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter); assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0))); assert_eq!(wave.regions.len(), 1); assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd)); @@ -260,6 +298,48 @@ fn vectors_import_as_editable_networks() { // A damaged network falls back to the outline. assert_eq!(network_of(&doc, "Broken").regions.len(), 1); assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}"); + let fallback = network_of(&doc, "Join Fallbacks"); + assert!(fallback.vertices.iter().all(|v| v.join.is_none())); + assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); + assert!( + warnings + .iter() + .any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")), + "{warnings:?}" + ); + assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); +} + +#[test] +fn per_segment_stroke_styles_are_reported() { + let styled_import = |segment_style: u32| { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); + let net = network::decode(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style), (4, segment_style)); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; + assert!(network::decode(&square_blob()).unwrap().segments.iter().all(|s| s.style == 0)); + let plain = import(&tail_canvas(), "Tail").unwrap().1; + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -366,6 +446,29 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let decoded = network::decode(&bytes).unwrap(); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); @@ -392,8 +495,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..b0c3c5fe 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -33,12 +33,13 @@ enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum LeadingTrim NONE CAP_HEIGHT struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position -message Image byte[] hash, string name +message Image byte[] hash, string name, uint dataBlob message AssetRef string key, string version message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId @@ -72,7 +73,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -283,6 +284,7 @@ fn message() -> Kv<'static> { ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("fontSize", f(16.0)), ("fontName", font("Regular")), + ("leadingTrim", e("CAP_HEIGHT")), ("textData", text), ], ), @@ -390,6 +392,175 @@ fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::N doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) } +/// The shared schema plus the grid sizing definitions only these tests use. +fn grid_schema() -> Schema { + const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; + const TYPES: &str = " +enum GridTrackSizingType FLEX FIXED HUG UNKNOWN +message GridTrackSizeFunction GridTrackSizingType type, float value +message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing +message GridTrackSizeEntry GUID id, GridTrackSize trackSize +message GridTrackSizeMap GridTrackSizeEntry[] entries +message GridTrackEntry GUID id, string position +message GridTrackMap GridTrackEntry[] entries +message Blob "; + let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( + "\nmessage Blob ", + &format!("{NODE_FIELDS}{TYPES}"), + 1, + ); + Schema::parse(&text).unwrap() +} + +fn track_size(kind: &'static str, value: f64) -> Kv<'static> { + let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); + o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) +} + +fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { + o(vec![("minSizing", min), ("maxSizing", max)]) +} + +fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("trackSize", size)]) +} + +fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { + o(vec![("id", g(id)), ("position", s(position))]) +} + +fn unpositioned_track(id: u64) -> Kv<'static> { + o(vec![("id", g(id))]) +} + +#[test] +fn grid_tracks_import_by_guid_and_keep_track_order() { + let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); + let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); + // Sizing entries intentionally differ from track order. Their GUIDs define the join. + let col_sizes = arr(vec![ + track_map_entry(12, track_size("FLEX", 1.0)), + track_map_entry(10, track_size("FIXED", 120.0)), + track_map_entry(13, track_size("FLEX", 2.0)), + track_map_entry(11, track_size("HUG", 80.0)), + ]); + let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(600.0, 300.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", cols)])), + ("gridRows", o(vec![("entries", rows)])), + ("gridColumnsSizing", o(vec![("entries", col_sizes)])), + ("gridRowsSizing", o(vec![("entries", row_sizes)])), + ("gridColumnGap", f(10.0)), + ("gridRowGap", f(12.0)), + ], + ), + node( + 3, + Some((2, "!")), + "RECTANGLE", + "Hug child", + vec![ + ("size", xy(80.0, 30.0)), + ("transform", at(0.0, 0.0)), + ("gridColumnAnchor", g(11)), + ("gridRowAnchor", g(21)), + ("gridColumnSpan", u(2)), + ("gridRowSpan", u(1)), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); + let grid = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); + assert_eq!( + grid.column_tracks, + vec![ + figcraft_doc::GridTrack::fixed(120.0), + figcraft_doc::GridTrack::hug(), + figcraft_doc::GridTrack::fill(1.0), + figcraft_doc::GridTrack::fill(2.0), + ] + ); + assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); + let child = &named(&doc, "Hug child").child_layout; + assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); + assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); +} + +#[test] +fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { + let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); + let sizing = arr(vec![ + track_map_entry(30, track_size("FIXED", -1.0)), + track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), + track_map_entry(33, track_size("UNKNOWN", 5.0)), + track_map_entry(34, track_size("FLEX", 3.0)), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", tracks)])), + ("gridColumnsSizing", o(vec![("entries", sizing)])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); + assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); +} + +#[test] +fn oversized_grid_track_lists_warn_and_use_default_tracks() { + let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); + let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", arr(oversized))])), + ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); + assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); +} + #[test] fn kiwi_round_trip() { let sc = schema(); @@ -477,6 +648,7 @@ fn imports_the_fixture() { let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); + assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). @@ -539,6 +711,53 @@ fn bare_canvas_and_zlib_chunks_open() { assert!(import(&out, "Z").is_ok()); } +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none()); @@ -588,7 +807,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -598,8 +817,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -621,6 +840,61 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -873,6 +1147,12 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "image_filter_tests.rs"] +mod image_filters; + +#[path = "text_list_tests.rs"] +mod text_list; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; @@ -916,7 +1196,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs new file mode 100644 index 00000000..eca7b0dc --- /dev/null +++ b/crates/format/src/fig/text_list_tests.rs @@ -0,0 +1,93 @@ +//! Numbered lists that start past 1 (#686): each text line says where its list starts counting, +//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers. + +use figcraft_doc::{ListStyle, NodeKind, ParaList}; + +use super::*; + +const LIST_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A list line: type, start offset, first of its list. +fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> { + let level = i64::from(ty != "PLAIN"); + o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect() +} + +#[test] +fn numbered_lists_keep_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]), + text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]), + text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]), + text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]), + text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]), + text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]), + ]; + let sc = Schema::parse(LIST_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(starts(&doc, "From 1"), [(N, 0)]); + assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); + assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); + assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start"); + assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); +} + +#[test] +fn the_export_writes_where_a_list_starts() { + let mut doc = figcraft_doc::Document::new("Lists"); + let page = doc.first_page().unwrap(); + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; + doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); + // Every line of a numbered list carries its first item's start (a reader that takes it per + // line or per list counts the same); bullets and plain lines carry none. + assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]); +} diff --git a/crates/format/src/fig/text_paragraph_tests.rs b/crates/format/src/fig/text_paragraph_tests.rs new file mode 100644 index 00000000..b3f05b05 --- /dev/null +++ b/crates/format/src/fig/text_paragraph_tests.rs @@ -0,0 +1,288 @@ +//! Paragraph options (list spacing, hanging list and punctuation, leading trim) in encoded `.fig` +//! files: the highest valid value wins, invalid instance rows fall through, native saves keep them. + +use super::kiwi::Kv; +use super::{export, import}; + +fn paragraph_fig(shared: Vec<(&'static str, Kv<'static>)>, local: Vec<(&'static str, Kv<'static>)>) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float paragraphSpacing, float paragraphIndent, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let mut base = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + base.extend(shared); + let mut layer = vec![ + ("fontName", font("Stale")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ]; + layer.extend(local); + let msg = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(10, "TEXT", "Shared", Some(2), base), + node(11, "TEXT", "Label", Some(2), layer), + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let message = encode::message(&schema, root, &msg).unwrap(); + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() { + let shared = vec![ + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ]; + let (inherited, warnings) = import(¶graph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = inherited.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + assert_eq!(text.style.font_family, "Inter"); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (cleared, warnings) = import(¶graph_fig(shared, reset), "Synthetic paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = cleared.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + assert_eq!(text.style.font_family, "Inter"); + + let bytes = crate::save_bytes(&inherited, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let (_, export_warnings) = export(&inherited, None).unwrap(); + assert!( + export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")), + "existing .fig export loss must remain visible: {export_warnings:?}" + ); +} + +fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT SYMBOL INSTANCE\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + struct GUIDPath GUID[] guids\n\ + struct SymbolOverride GUIDPath guidPath, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + struct SymbolData GUID symbolID, SymbolOverride[] symbolOverrides\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim, SymbolData symbolData\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ], + ); + let mut override_fields = vec![("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))]))]; + override_fields.extend(overrides); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![object(override_fields)]))]))], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node( + 10, + "TEXT", + "Shared", + Some(2), + vec![ + ("styleType", Kv::Enum("TEXT")), + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(true)), + ("hangingPunctuation", Kv::Bool(true)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ], + ), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let mut message = encode::message(&schema, root, &message).unwrap(); + if nonfinite_spacing { + let finite = 37.0_f32.to_bits().rotate_right(23).to_le_bytes(); + let nan = f32::NAN.to_bits().rotate_right(23).to_le_bytes(); + let matches: Vec = message.windows(finite.len()).enumerate().filter_map(|(i, w)| (w == finite).then_some(i)).collect(); + assert_eq!(matches.len(), 1, "finite test token must identify the instance listSpacing field"); + let offset = matches[0]; + message[offset..offset + nan.len()].copy_from_slice(&nan); + } + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() { + let valid = vec![ + ("listSpacing", Kv::Float(24.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(valid, false), "Synthetic instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 24.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let invalid = vec![ + ("listSpacing", Kv::Float(37.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Uint(77)), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(invalid, true), "Synthetic malformed instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let bytes = crate::save_bytes(&doc, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let copy = back.nodes().find(|node| node.name == "Copy").unwrap(); + let label = back.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); +} diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..c94c6aa3 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs new file mode 100644 index 00000000..48566384 --- /dev/null +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -0,0 +1,272 @@ +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip +//! ink only shows on an underline, so the fixtures are underlined. + +use super::kiwi::Kv; +use super::{export, import}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, TextStyle}; +use serde_json::Value; + +fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(8)), ("localID", Kv::Uint(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.append(&mut fields); + object(out) +} + +fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec, shared: Option>>) -> Value { + let mut layer = vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), + ( + "textData", + object(vec![ + ("characters", Kv::Str(chars.into())), + ("characterStyleIDs", Kv::Array(ids.into_iter().map(Kv::Uint).collect())), + ("styleOverrideTable", Kv::Array(runs)), + ]), + ), + ]; + if let Some(value) = base { + layer.push(("textDecorationSkipInk", value)); + } + let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + if let Some(shared) = shared { + let mut fields = + vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0)), ("textDecoration", Kv::Enum("UNDERLINE"))]; + if let Some(value) = shared { + fields.push(("textDecorationSkipInk", value)); + } + nodes.push(node(10, "TEXT", "Body", Some(2), fields)); + layer.push(("styleIdForText", object(vec![("guid", guid(10))]))); + } + nodes.push(node(11, "TEXT", "Label", Some(2), layer)); + let message = object(vec![("nodeChanges", Kv::Array(nodes))]); + super::map::to_rest(&message, "Synthetic skip ink", crate::rest::MAX_NODES).0 +} + +fn imported(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn label(doc: &Document) -> &TextData { + doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap() +} + +fn skip_at(text: &TextData, index: usize) -> bool { + text.runs.iter().find(|r| r.start <= index && index < r.end).map_or_else(|| text.style.look().skip_ink, |r| r.style.look().skip_ink) +} + +#[test] +fn node_boolean_false_true_omitted_and_malformed_keep_typed_semantics() { + for (raw_value, expected) in [(Some(Kv::Bool(true)), true), (Some(Kv::Bool(false)), false), (None, false), (Some(Kv::Str("true".into())), false)] + { + let doc = imported(&raw(raw_value.clone(), vec![], "A", vec![0], None)); + assert_eq!(label(&doc).style.look().skip_ink, expected, "source value {raw_value:?}"); + } +} + +#[test] +fn runs_inherit_explicit_false_and_true_by_utf16_character_ids() { + let table = vec![ + object(vec![("styleID", Kv::Uint(1))]), + object(vec![("styleID", Kv::Uint(2)), ("textDecorationSkipInk", Kv::Bool(false))]), + object(vec![("styleID", Kv::Uint(3)), ("textDecorationSkipInk", Kv::Bool(true))]), + ]; + let doc = imported(&raw(Some(Kv::Bool(true)), table, "A😀BC", vec![0, 1, 1, 2, 3], None)); + let text = label(&doc); + assert_eq!(text.characters.chars().count(), 4); + assert_eq!(text.characters.encode_utf16().count(), 5, "the emoji occupies two source style-ID units"); + assert_eq!((0..text.char_count()).map(|i| skip_at(text, i)).collect::>(), vec![true, true, false, true]); + + let false_base = imported(&raw( + Some(Kv::Bool(false)), + vec![object(vec![("styleID", Kv::Uint(1)), ("textDecorationSkipInk", Kv::Bool(true))])], + "ab", + vec![1, 0], + None, + )); + assert_eq!((0..2).map(|i| skip_at(label(&false_base), i)).collect::>(), vec![true, false]); +} + +#[test] +fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { + for (local, expected) in [(Some(false), false), (None, true)] { + let value = raw(local.map(Kv::Bool), vec![], "ab", vec![0, 0], Some(Some(Kv::Bool(true)))); + let doc = imported(&value); + let node = doc.nodes().find(|n| n.name == "Label").unwrap(); + assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); + let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + assert!(style.look().skip_ink, "local value must not alter definition"); + } +} + +#[test] +fn instance_path_override_preserves_explicit_false_over_true_main() { + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), + ("textDecorationSkipInk", Kv::Bool(true)), + ("textData", object(vec![("characters", Kv::Str("A".into()))])), + ], + ); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("textDecorationSkipInk", Kv::Bool(false)), + ])]), + ), + ]), + )], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let value = super::map::to_rest(&message, "Instance skip ink", crate::rest::MAX_NODES).0; + let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap(); + let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(label["style"]["textDecorationSkipInk"], false); + let doc = imported(&value); + let copy = doc.nodes().find(|n| n.name == "Copy").unwrap(); + let instance_label = doc.node(*copy.children.first().unwrap()).unwrap(); + assert!(!instance_label.kind.text().unwrap().style.look().skip_ink); +} + +#[test] +fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { + let mut doc = Document::new("Skip ink export"); + let mut text = TextData::new("abcd"); + text.style.decoration = TextDecoration::Underline; + text.style.set_look(|look| look.skip_ink = false); + let mut skipped = text.style.clone(); + skipped.set_look(|look| look.skip_ink = true); + text.runs = vec![StyleRun { start: 1, end: 3, style: skipped }]; + doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink is supported: {warnings:?}"); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], false); + assert_eq!(layer.as_object().unwrap().keys().filter(|k| *k == "textDecorationSkipInk").count(), 1); + let rows = layer["textData"]["styleOverrideTable"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["textDecorationSkipInk"], true); + let (back, warnings) = import(&bytes, "Skip ink export").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!((0..4).map(|i| skip_at(label(&back), i)).collect::>(), vec![false, true, true, false]); + + let mut custom = TextData::new("x"); + custom.style.set_look(|look| look.skip_ink = true); + custom.style.set_look(|look| look.thickness = figcraft_doc::DecorationLength::Pixels(2.0)); + let mut custom_doc = Document::new("Unsupported decoration"); + custom_doc.create(custom_doc.first_page().unwrap(), NodeKind::Text(custom), "Custom", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&custom_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("text decoration looks")), "custom look warning missing: {warnings:?}"); + + let mut variable = TextData::new("x"); + variable.style.variations.insert("wght".into(), 400.0); + let mut variable_doc = Document::new("Variable font warning"); + variable_doc.create(variable_doc.first_page().unwrap(), NodeKind::Text(variable), "Variable", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&variable_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); + + let mut shared = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; + shared.set_look(|look| look.skip_ink = true); + let mut local = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; + local.set_look(|look| look.skip_ink = false); + let mut linked_doc = Document::new("Shared skip ink"); + linked_doc.styles.push(figcraft_doc::Style { + id: "body".into(), + name: "Body".into(), + description: String::new(), + kind: StyleKind::Text { style: shared }, + bound: Default::default(), + }); + let id = linked_doc + .create(linked_doc.first_page().unwrap(), NodeKind::Text(TextData { style: local, ..TextData::new("x") }), "Label", 0.0, 0.0, 100.0, 30.0) + .unwrap(); + linked_doc.node_mut(id).unwrap().styles.text = Some("body".into()); + let (bytes, warnings) = export(&linked_doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink-only shared styles are supported: {warnings:?}"); + let (back, warnings) = import(&bytes, "Shared skip ink").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let node = back.nodes().find(|n| n.name == "Label").unwrap(); + assert!(!node.kind.text().unwrap().style.look().skip_ink); + let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + assert!(style.look().skip_ink, "local false must not alter the shared definition"); +} + +#[test] +fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() { + // Plain text with the files' default value imports with the default look... + let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!(label(&doc).style.decoration, TextDecoration::None); + assert!(label(&doc).style.decoration_look.is_none()); + + // ...an underlined run of it keeps the layer's value... + let mut value = + raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::>(), vec![true, false]); + + // ...and default native text writes the default, which reads back as the default look. + let mut doc = Document::new("Plain"); + doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], true); + let (back, _) = import(&bytes, "Plain").unwrap(); + assert!(label(&back).style.decoration_look.is_none()); +} diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs new file mode 100644 index 00000000..b2c4ed59 --- /dev/null +++ b/crates/format/src/fig/text_style_tests.rs @@ -0,0 +1,292 @@ +//! Text layers and their shared text styles on import: which of the style, the layer's own +//! values and an instance's overrides win, on messages built in code. + +use figcraft_doc::{Document, LetterSpacing, StyleKind, TextCase, TextDecoration}; +use serde_json::Value; + +use super::kiwi::Kv; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +const NO_STYLE: u64 = 4_294_967_295; + +fn object(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))]) +} + +fn link(id: u64) -> Kv<'static> { + object(vec![("guid", guid(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + fields.append(&mut more); + object(fields) +} + +fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn chars(text: &str) -> Kv<'static> { + object(vec![("characters", Kv::Str(text.into()))]) +} + +/// The REST JSON of a page holding `nodes`. +fn rest(nodes: Vec>) -> Value { + let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]); + super::map::to_rest(&message, "Synthetic text", crate::rest::MAX_NODES).0 +} + +fn import(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +/// Depth-first search for a REST node by name below `v`. +fn find<'v>(v: &'v Value, name: &str) -> Option<&'v Value> { + if v["name"] == name { + return Some(v); + } + v["children"].as_array()?.iter().find_map(|c| find(c, name)) +} + +/// A shared text style (Inter, 20 px, with `shared` fields) and a layer linking it whose own +/// copy of the typography is stale (40 px, another family) and which sets `local`. +fn linked(shared: Fields, local: Fields) -> Document { + let mut definition = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + definition.extend(shared); + let mut layer = vec![("fontName", font("Stale copy")), ("fontSize", Kv::Float(40.0)), ("styleIdForText", link(10)), ("textData", chars("a\nb"))]; + layer.extend(local); + import(&rest(vec![node(10, "TEXT", "Shared", Some(2), definition), node(11, "TEXT", "Label", Some(2), layer)])) +} + +struct Seen { + case: TextCase, + decoration: TextDecoration, + spacing: f64, + indent: f64, + shared_case: TextCase, +} + +fn seen(doc: &Document) -> Seen { + let label = doc.nodes().find(|n| n.name == "Label").unwrap(); + let text = label.kind.text().unwrap(); + // The shared style's typography beats the layer's stale copy, whatever the layer sets locally. + assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); + let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); + assert_eq!(linked.name, "Shared"); + let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + Seen { + case: text.style.case, + decoration: text.style.decoration, + spacing: text.paragraph_spacing, + indent: text.paragraph_indent, + shared_case: style.case, + } +} + +#[test] +fn local_case_decoration_and_paragraph_values_beat_the_shared_style() { + let shared = vec![ + ("textCase", Kv::Enum("UPPER")), + ("textDecoration", Kv::Enum("UNDERLINE")), + ("paragraphSpacing", Kv::Float(24.0)), + ("paragraphIndent", Kv::Float(12.0)), + ]; + // Nothing set on the layer: the style's values stay. + let s = seen(&linked(shared.clone(), vec![])); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Upper, TextDecoration::Underline, 24.0, 12.0)); + // Explicit values on the layer win, and "original", "none" and zero clear the style's. + let cleared = vec![ + ("textCase", Kv::Enum("ORIGINAL")), + ("textDecoration", Kv::Enum("NONE")), + ("paragraphSpacing", Kv::Float(0.0)), + ("paragraphIndent", Kv::Float(0.0)), + ]; + let s = seen(&linked(shared.clone(), cleared)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Original, TextDecoration::None, 0.0, 0.0)); + assert_eq!(s.shared_case, TextCase::Upper, "a local value must not change the definition"); + let local = vec![ + ("textCase", Kv::Enum("SMALL_CAPS_FORCED")), + ("textDecoration", Kv::Enum("STRIKETHROUGH")), + ("paragraphSpacing", Kv::Float(6.0)), + ("paragraphIndent", Kv::Float(3.0)), + ]; + let s = seen(&linked(vec![], local)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::SmallCaps, TextDecoration::Strikethrough, 6.0, 3.0)); + // A value the importer does not know leaves the style's in place. + let s = seen(&linked(shared, vec![("textCase", Kv::Enum("UNSUPPORTED_CASE")), ("textDecoration", Kv::Enum("WAVY"))])); + assert_eq!((s.case, s.decoration), (TextCase::Upper, TextDecoration::Underline)); +} + +#[test] +fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() { + let value = rest(vec![ + node(10, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(10)), ("textData", chars("Hi"))], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("fontSize", Kv::Float(30.0)), + ])]), + ), + ]), + )], + ), + // An explicit "no style" detaches, even when the older field still names a style. + node( + 40, + "TEXT", + "Detached", + Some(2), + vec![ + ("fontName", font("Roboto")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", object(vec![("sessionID", Kv::Uint(NO_STYLE)), ("localID", Kv::Uint(NO_STYLE))]))])), + ("inheritTextStyleID", guid(10)), + ("textData", chars("ab")), + ], + ), + ]); + let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap(); + assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0), "the override beats the style"); + let detached = find(&value["document"], "Detached").unwrap(); + assert!(detached["styles"].get("text").is_none(), "{}", detached["styles"]); + assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0)); +} + +#[test] +fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() { + let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec(); + let value = rest(vec![ + node( + 20, + "SYMBOL", + "Main", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![object(vec![ + ("id", guid(70)), + ("name", Kv::Str("Text".into())), + ("type", Kv::Enum("TEXT")), + ("initialValue", object(vec![("textValue", chars("Hi you"))])), + ])]), + )], + ), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(12.0)), + ( + "textData", + object(vec![ + ("characters", Kv::Str("Hi you".into())), + ("characterStyleIDs", Kv::Array(ids)), + ("styleOverrideTable", Kv::Array(vec![object(vec![("styleID", Kv::Uint(1)), ("fontSize", Kv::Float(30.0))])])), + ]), + ), + ( + "parameterConsumptionMap", + object(vec![( + "entries", + Kv::Array(vec![object(vec![ + ("variableField", Kv::Enum("TEXT_DATA")), + ("variableData", object(vec![("value", object(vec![("propRefValue", object(vec![("defId", guid(70))]))]))])), + ])]), + )]), + ), + ], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![ + ("symbolData", object(vec![("symbolID", guid(20))])), + ( + "componentPropAssignments", + Kv::Array(vec![object(vec![("defID", guid(70)), ("value", object(vec![("textValue", chars("Goodbye"))]))])]), + ), + ], + ), + ]); + let main = find(find(&value["document"], "Main").unwrap(), "Label").unwrap(); + let kept: Vec = main["characterStyleOverrides"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + assert_eq!(kept, [0, 0, 0, 1, 1, 1], "the main keeps its run styles"); + let inst = find(&value["document"], "Inst").unwrap(); + let layer = find(inst, "Label").unwrap(); + assert_eq!(layer["characters"], "Goodbye"); + assert!(layer.get("characterStyleOverrides").is_none(), "positions of the main's runs do not fit the property text"); +} + +#[test] +fn percent_letter_spacing_stays_a_percentage() { + let spacing = |units: &'static str, value: f32| object(vec![("value", Kv::Float(value)), ("units", Kv::Enum(units))]); + let layer = |id, name: &str, letter: Kv<'static>| { + node( + id, + "TEXT", + name, + Some(2), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(20.0)), ("letterSpacing", letter), ("textData", chars("Hello"))], + ) + }; + let doc = import(&rest(vec![layer(10, "Percent", spacing("PERCENT", 5.0)), layer(11, "Pixels", spacing("PIXELS", 2.0))])); + let spacing = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing("Percent"), LetterSpacing::Percent(5.0)); + assert_eq!(spacing("Pixels"), LetterSpacing::Pixels(2.0)); +} + +#[test] +fn the_private_percent_is_read_for_fig_imports_only() { + let value = rest(vec![node( + 10, + "TEXT", + "Label", + Some(2), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(20.0)), + ("letterSpacing", object(vec![("value", Kv::Float(5.0)), ("units", Kv::Enum("PERCENT"))])), + ("textData", chars("Hello")), + ], + )]); + let spacing = |doc: &Document| doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing(&import(&value)), LetterSpacing::Percent(5.0)); + // The same JSON through the public REST import is pixels. + let (public, _) = crate::rest::import(&value, &|_| None).unwrap(); + assert_eq!(spacing(&public), LetterSpacing::Pixels(1.0)); +} diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,20 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + if cfg!(not(target_pointer_width = "32")) { + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + } + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,15 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower. +const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +464,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +490,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result { + rest: &'a str, + guid: &'a str, + expanded: bool, + slot_content: bool, + wire_frame: bool, + wire_instance: bool, + component: Option<&'a str>, +} + +pub(super) struct Sources<'a> { + /// Most layers recorded. + max_nodes: usize, + nodes: BTreeMap>, + duplicate_ids: BTreeSet<&'a str>, + rejected: usize, +} + +fn guid(s: &str) -> bool { + let Some((session, local)) = s.split_once(':') else { return false }; + s.len() <= 41 + && [session, local].into_iter().all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()) && part.parse::().is_ok()) +} + +fn expanded_id(instance: &str, origin: &str) -> Option { + let bytes = instance.len().checked_add(origin.len())?.checked_add(2)?; + if bytes > MAX_SOURCE_ID_BYTES { + return None; + } + Some(if instance.starts_with('I') { format!("{instance};{origin}") } else { format!("I{instance};{origin}") }) +} + +impl<'a> Sources<'a> { + pub(super) fn new(max_nodes: usize) -> Self { + Self { max_nodes, nodes: BTreeMap::new(), duplicate_ids: BTreeSet::new(), rejected: 0 } + } + + pub(super) fn record(&mut self, id: NodeId, node: &'a Value, primary: bool) { + let Some(rest) = node.get("id").and_then(Value::as_str) else { return }; + if !primary { + self.duplicate_ids.insert(rest); + return; + } + let Some(meta) = node.get(FIG_SOURCE_FIELD) else { return }; + let parsed = meta.get("origin").and_then(Value::as_str).zip(meta.get("expanded").and_then(Value::as_bool)); + let Some((origin, expanded)) = parsed else { + self.rejected += 1; + return; + }; + if self.nodes.len() >= self.max_nodes + || rest.len() > MAX_SOURCE_ID_BYTES + || !guid(origin) + || (!expanded && rest != origin) + || (expanded && !rest.starts_with('I')) + { + self.rejected += 1; + return; + } + let wire_frame = meta.get("wireFrame").and_then(Value::as_bool).unwrap_or(false); + let wire_instance = !wire_frame + && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) + && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); + let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); + let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component }); + } + + fn origin(&self, id: NodeId) -> Option> { + let meta = *self.nodes.get(&id)?; + (!self.duplicate_ids.contains(meta.rest)).then_some(meta) + } + + fn raw_in_main(&self, origin: &str, main: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let target = *ids.get(origin)?; + let meta = self.origin(target)?; + (!meta.expanded && meta.rest == origin && target != main && doc.is_ancestor(main, target)).then_some(target) + } + + fn root_source(&self, id: NodeId, doc: &Document) -> Option { + let NodeKind::Instance { component, .. } = &doc.node(id)?.kind else { return None }; + let main_meta = self.origin(*component)?; + (!main_meta.expanded + && matches!(doc.node(*component)?.kind, NodeKind::Component { .. }) + && *component != id + && !doc.is_ancestor(id, *component) + && !doc.is_ancestor(*component, id)) + .then_some(*component) + } + + fn missing_frame(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + self.origin(id).is_some_and(|meta| { + meta.wire_instance + && matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::Frame)) + // An existing target of the wrong kind or a cycle-blocked main is not missing. + && meta.component.is_some_and(|main| !ids.contains_key(main)) + }) + } + + fn owner(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let mut current = doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..512 { + let parent = current?; + if !seen.insert(parent) { + return None; + } + let node = doc.node(parent)?; + if matches!(node.kind, NodeKind::Instance { .. }) || self.missing_frame(parent, doc, ids) { + return Some(parent); + } + current = node.parent; + } + None + } + + fn namespace(&self, id: NodeId, owner: NodeId, doc: &Document) -> bool { + let Some(owner_meta) = self.origin(owner) else { return false }; + let mut current = id; + let mut seen = BTreeSet::new(); + for _ in 0..512 { + if current == owner { + return true; + } + if !seen.insert(current) { + return false; + } + let Some(meta) = self.origin(current) else { return false }; + if !meta.expanded || expanded_id(owner_meta.rest, meta.guid).as_deref() != Some(meta.rest) { + return false; + } + let Some(parent) = doc.node(current).and_then(|n| n.parent) else { return false }; + current = parent; + } + false + } + + // A missing-main instance is materialized as a frame. Outside real instances its children + // are owned layers; known instances there become ordinary roots with their own actual main. + fn owned_layer(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + if doc.ancestors(id).into_iter().any(|p| doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) { + return false; + } + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(owner) = self.owner(current, doc, ids) else { return false }; + if !self.missing_frame(owner, doc, ids) || !self.namespace(current, owner, doc) { + return false; + } + let Some(meta) = self.origin(owner) else { return false }; + if !meta.expanded { + return true; + } + current = owner; + } + false + } + + fn frame_target(&self, id: NodeId, owner: NodeId, source_owner: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let meta = self.origin(id)?; + let owner_meta = self.origin(owner)?; + let source_meta = self.origin(source_owner)?; + if !self.missing_frame(source_owner, doc, ids) || owner_meta.component != source_meta.component { + return None; + } + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + if !target_meta.expanded + || target_meta.guid != meta.guid + || self.owner(target, doc, ids) != Some(source_owner) + || !self.namespace(target, source_owner, doc) + { + return None; + } + let parent = doc.node(id)?.parent?; + let target_parent = doc.node(target)?.parent?; + if parent == owner { + (target_parent == source_owner).then_some(target) + } else { + (target_parent != source_owner && self.origin(parent)?.guid == self.origin(target_parent)?.guid).then_some(target) + } + } + + fn source( + &self, + id: NodeId, + doc: &Document, + ids: &BTreeMap, + memo: &mut BTreeMap>, + active: &mut BTreeSet, + depth: usize, + ) -> Option { + if let Some(source) = memo.get(&id) { + return *source; + } + if depth > MAX_DEPTH || !active.insert(id) { + return None; + } + let found = (|| { + let meta = self.origin(id)?; + let _ = doc.node(id)?; + if meta.slot_content { + return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None }; + } + if !meta.expanded { + return self.root_source(id, doc); + } + // An actual wire INSTANCE whose main is absent remains a namespace owner even + // when the REST importer materializes it as a frame. Ordinary frames never do. + let owner = self.owner(id, doc, ids)?; + if !self.namespace(id, owner, doc) { + return None; + } + let source_owner = self.source(owner, doc, ids, memo, active, depth + 1); + if self.missing_frame(owner, doc, ids) { + let target = match source_owner { + Some(source_owner) => self.frame_target(id, owner, source_owner, doc, ids)?, + None if self.owned_layer(id, doc, ids) => return self.root_source(id, doc), + _ => return None, + }; + return (target != id && !doc.is_ancestor(id, target)).then_some(target); + } + let NodeKind::Instance { component: main, .. } = &doc.node(owner)?.kind else { return None }; + let source_owner = source_owner?; + let target = match &doc.node(source_owner)?.kind { + NodeKind::Component { .. } if source_owner == *main => self.raw_in_main(meta.guid, *main, doc, ids)?, + NodeKind::Instance { component: source_main, .. } if source_main != main => { + // A resolved main switch is evidence for resetting to that CURRENT main. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + NodeKind::Instance { component: source_main, .. } if source_main == main => { + let source_meta = self.origin(source_owner)?; + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + let target_owner = self.owner(target, doc, ids); + if !target_meta.expanded || target_meta.guid != meta.guid || target_owner != Some(source_owner) { + return None; + } + target + } + NodeKind::Frame if self.origin(source_owner)?.wire_frame => { + // An actual wire FRAME becoming INSTANCE has no previous main. Its resolved + // current main is the only proven namespace for its newly expanded children. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + _ => return None, + }; + (target != id && !doc.is_ancestor(id, target)).then_some(target) + })(); + active.remove(&id); + memo.insert(id, found); + found + } + + pub(super) fn apply(self, doc: &mut Document, ids: &BTreeMap) -> usize { + let mut links = BTreeMap::new(); + let mut active = BTreeSet::new(); + for &id in self.nodes.keys() { + let _ = self.source(id, doc, ids, &mut links, &mut active, 0); + } + let mut missing = self.rejected; + for (&id, meta) in &self.nodes { + let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids)) + || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + if !needs_source { + continue; + } + let target = links.get(&id).copied().flatten().filter(|_| acyclic(id, &links)); + if let Some(target) = target { + if let Some(node) = doc.node_mut(id) { + node.source = Some(target); + } + } else { + missing += 1; + } + } + missing + } +} + +fn acyclic(id: NodeId, links: &BTreeMap>) -> bool { + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(next) = links.get(¤t).copied().flatten() else { return true }; + if next == id { + return false; + } + current = next; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_cycles_and_overlong_source_chains_are_rejected() { + let self_link = BTreeMap::from([(NodeId(1), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &self_link)); + let cycle = BTreeMap::from([(NodeId(1), Some(NodeId(2))), (NodeId(2), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &cycle)); + let long = (1..=MAX_DEPTH as u64 + 2).map(|i| (NodeId(i), Some(NodeId(i + 1)))).collect(); + assert!(!acyclic(NodeId(1), &long)); + assert!(expanded_id(&"x".repeat(MAX_SOURCE_ID_BYTES), "1:2").is_none()); + } +} diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs new file mode 100644 index 00000000..2d6364be --- /dev/null +++ b/crates/format/src/rest/import_regression_tests.rs @@ -0,0 +1,59 @@ +//! Stroke-only vectors that import as the outline of their stroke keep the stroke's style and variables. + +use super::proto_variable_tests::{BRAND, alias, variables_json}; +use super::*; +use serde_json::json; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +fn file(children: Value) -> Value { + json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": children} + ]}}) +} + +fn named<'a>(doc: &'a Document, name: &str) -> &'a Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no node {name}")) +} + +/// A stroke-only icon vector as `geometry=paths` returns it: no fills, one visible stroke. +fn stroke_only_vector(id: &str, name: &str, stroke: Value) -> Value { + json!({"id": id, "name": name, "type": "VECTOR", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [], "strokes": [stroke], "strokeWeight": 2, + "fillGeometry": [], + "strokeGeometry": [{"path": "M 0 0 L 10 0 L 10 2 L 0 2 Z", "windingRule": "NONZERO"}], + "styles": {"stroke": "S:stroke"}}) +} + +#[test] +fn stroke_only_vector_shared_stroke_style_keeps_paints() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}); + let mut v = file(json!([stroke_only_vector("1:1", "Icon", stroke)])); + v["styles"] = json!({"S:stroke": {"key": "k", "name": "Brand/Stroke", "styleType": "FILL"}}); + let (doc, _) = import(&v, &no_images).unwrap(); + let icon = named(&doc, "Icon"); + assert!(matches!(icon.kind, NodeKind::Vector { .. })); + let style = doc.style("S:stroke").expect("shared stroke style is created"); + match &style.kind { + StyleKind::Paint { paints } => assert_eq!(paints.len(), 1, "shared stroke style must keep the stroke paint, got {paints:?}"), + k => panic!("unexpected style kind {k:?}"), + } +} + +#[test] +fn stroke_colour_variable_on_outlined_vector_is_kept() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}}); + let mut node = stroke_only_vector("1:1", "Icon", stroke); + node.as_object_mut().unwrap().remove("styles"); + node["boundVariables"] = json!({"strokes": [alias(BRAND)]}); + let (doc, warnings) = import_with(&file(json!([node])), Some(&variables_json()), &no_images).unwrap(); + let icon = named(&doc, "Icon"); + // The stroke paint now paints the outline as its fill: its colour token must follow it. + assert!( + icon.bound.values().any(|v| v == BRAND), + "colour variable bound on the outlined stroke was dropped; bound={:?} warnings={warnings:?}", + icon.bound + ); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..953522e1 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -16,7 +16,8 @@ //! //! Instances come out with their own layers as the file lists them. Linking those layers to the //! main component's (`source`) and deriving overrides is left to the caller (the engine does it -//! after loading), since that needs the component model. +//! after loading), since that needs the component model. The private .fig entry point additionally +//! preserves explicit GUID/expansion provenance before the engine derives overrides. #[cfg(test)] mod blur_tests; @@ -24,8 +25,11 @@ mod blur_tests; mod conditional_tests; #[cfg(test)] mod effects_tests; +mod fig_sources; #[cfg(test)] mod gamepad_tests; +#[cfg(test)] +mod import_regression_tests; pub(crate) mod props; pub(crate) mod proto; #[cfg(test)] @@ -49,16 +53,20 @@ use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; pub use variables::export as export_variables; -/// Most layers imported from one file. +/// Most layers imported from one file. The `.fig` import sets its own, from the file's size. pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; +/// Private .fig provenance, consumed only by the `.fig` import ([`import_with_networks`]). +pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; +/// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]). +pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. const MAX_PATH_ELEMENTS: usize = 200_000; /// Most distinct warnings reported. -const MAX_WARNINGS: usize = 200; +pub(crate) const MAX_WARNINGS: usize = 200; /// Most shared style definitions read. const MAX_STYLES: usize = 10_000; /// Image key used for image fills without an `imageRef`. @@ -138,12 +146,43 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_parts(v, variables, images, Vec::new(), MAX_NODES, false) +} + +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +/// The `.fig` import itself goes through [`import_with_networks`]. +#[cfg(test)] +pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_with_networks(v, images, Vec::new(), MAX_NODES) +} + +/// [`import_fig`] for the `.fig` import's JSON, whose vectors name their networks by +/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON). +/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`]. +pub(crate) fn import_with_networks( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes, true) +} + +fn import_parts( + v: &Value, + variables: Option<&Value>, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, + fig_sources: bool, +) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(max_nodes)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -157,6 +196,8 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } cx.style_definitions(v); + cx.networks = networks; + cx.max_nodes = max_nodes; for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -178,6 +219,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) None => cx.warn("entries without a node type were skipped"), } } + if fig_sources { + for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); + } + } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank && cx.doc.pages.len() > 1 @@ -210,7 +257,7 @@ fn style_binding(slot: &str, key: &str) -> Option { /// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke /// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that /// isn't such a shape is ignored. -fn restore_stroke(n: &mut Node, original: &Value) { +fn restore_stroke(n: &mut Node, original: &Value) -> bool { #[derive(serde::Deserialize)] struct Original { kind: NodeKind, @@ -218,16 +265,16 @@ fn restore_stroke(n: &mut Node, original: &Value) { strokes: Vec, stroke: figcraft_doc::StrokeProps, } - let Ok(mut o) = serde_json::from_value::(original.clone()) else { return }; + let Ok(mut o) = serde_json::from_value::(original.clone()) else { return false }; match &mut o.kind { NodeKind::Vector { network } => { if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS { - return; + return false; } network.sanitize(); } NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {} - _ => return, + _ => return false, } let sp = &mut o.stroke; sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE); @@ -237,6 +284,28 @@ fn restore_stroke(n: &mut Node, original: &Value) { o.fills.truncate(64); o.strokes.truncate(64); (n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke); + true +} + +/// A stroke-only vector imported as the outline of its stroke: the stroke paints fill the +/// outline, and the stroke's shared style and colour variables move with them. The hidden fills +/// it replaces go, with their style and bindings. +fn outline_stroke(n: &mut Node) { + n.fills = std::mem::take(&mut n.strokes); + n.styles.fill = n.styles.stroke.take(); + let bound = std::mem::take(&mut n.bound); + for (key, var) in bound { + let moved = match key.as_str() { + "fill" => continue, + k if k.starts_with("fills/") => continue, + "stroke" => "fill".to_string(), + k => match k.strip_prefix("strokes/") { + Some(rest) => format!("fills/{rest}"), + None => key, + }, + }; + n.bound.insert(moved, var); + } } fn children(v: &Value) -> &[Value] { @@ -291,6 +360,7 @@ pub(crate) struct Ctx<'a> { count: usize, /// REST node id → imported node. ids: BTreeMap, + fig_sources: Option>, component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, @@ -309,6 +379,10 @@ pub(crate) struct Ctx<'a> { /// Imported variables by id, and the mode ids of imported collections (default first). var_kinds: BTreeMap, var_modes: BTreeMap>, + /// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`. + networks: Vec>, + /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. + max_nodes: usize, } impl<'a> Ctx<'a> { @@ -321,6 +395,7 @@ impl<'a> Ctx<'a> { warnings: Warnings::default(), count: 0, ids: BTreeMap::new(), + fig_sources: None, component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), @@ -332,6 +407,8 @@ impl<'a> Ctx<'a> { overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), var_modes: BTreeMap::new(), + networks: Vec::new(), + max_nodes: MAX_NODES, } } @@ -387,8 +464,9 @@ impl<'a> Ctx<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return; } let Some(ty) = text(j, "type") else { @@ -403,6 +481,12 @@ impl<'a> Ctx<'a> { let id = self.doc.alloc_id(); let label = kind.label(); let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h); + // Overrides the .fig names explicitly (the rest are found by comparing with the main). + for (flag, group) in [("slotOverride", "slot"), ("sizeOverride", "size")] { + if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get(flag)).and_then(Value::as_bool) == Some(true) { + n.overrides.insert(group.into()); + } + } n.transform = local(space.world, world); self.common(&mut n, j, parent_mode); let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode)); @@ -411,7 +495,10 @@ impl<'a> Ctx<'a> { } self.count += 1; if let Some(rid) = text(j, "id") { - self.ids.entry(rid.to_string()).or_insert(id); + let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id; + if let Some(sources) = &mut self.fig_sources { + sources.record(id, j, primary); + } } if ty == "INSTANCE" { self.instances.push((id, j)); @@ -548,19 +635,18 @@ impl<'a> Ctx<'a> { if let Some(c) = flag(j, "clipsContent") { n.clip = c; } - n.layout = props::auto_layout(j); + n.layout = props::auto_layout(j, self.fig_sources.is_some()); n.grids = props::layout_grids(j); } n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); n.exports = props::export_settings(j); for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() { - if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) { + let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some()); + if let (Some(p), true) = (prop.as_str(), supported) { n.prop_refs.insert(field.clone(), p.to_string()); } } - if matches!(n.kind, NodeKind::Vector { .. }) { - self.vector(n, j); - } + let outlined = matches!(n.kind, NodeKind::Vector { .. }) && self.vector(n, j); if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) { let defs = self.prop_defs(n.id, j); match &mut n.kind { @@ -581,20 +667,28 @@ impl<'a> Ctx<'a> { self.overlay_meta.entry(rid).or_insert(j); } self.bindings(n, j); - if let Some(original) = j.get("outlinedStroke") { - restore_stroke(n, original); + let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original)); + // Styles and bindings were read against the stroke: they follow its paints to the fill. + if outlined && !restored { + outline_stroke(n); } } - /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our - /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape). - fn vector(&mut self, n: &mut Node, j: &Value) { - if let Some(net) = j.get("vectorNetwork") { - match network(net) { + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or + /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its + /// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints + /// (exact look, editable as a shape): returns whether it does, and the caller moves the paints + /// with [`outline_stroke`] once the styles and variables of the stroke are read. + fn vector(&mut self, n: &mut Node, j: &Value) -> bool { + let net = match j.get("vectorNetworkRef") { + Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())), + None => j.get("vectorNetwork").map(network), + }; + if let Some(net) = net { + match net { Some(network) => { n.kind = NodeKind::Vector { network }; - return; + return false; } None => self.warn("invalid or oversized vector networks import as outlines"), } @@ -609,17 +703,15 @@ impl<'a> Ctx<'a> { (None, Some(s)) => (s, true), (None, None) => { self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)"); - return; + return false; } }; let mut network = VectorNetwork::from_path(&path); for r in &mut network.regions { r.winding = winding; } - if outline { - n.fills = std::mem::take(&mut n.strokes); - } n.kind = NodeKind::Vector { network }; + outline } /// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data). @@ -658,6 +750,7 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, + Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, _ => { self.warn("unknown component property types were skipped"); continue; @@ -822,6 +915,12 @@ impl<'a> Ctx<'a> { } } self.resolve_instances(); + if let Some(sources) = self.fig_sources.take() { + let missing = sources.apply(&mut self.doc, &self.ids); + if missing > 0 { + self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked")); + } + } self.rename_props(); for (id, j) in std::mem::take(&mut self.protos) { let list = self.interactions(j); @@ -1055,6 +1154,16 @@ fn override_group(field: &str) -> Option<&'static str> { | "textDecorationSkipInk" | "textDecorationColor" => "textStyle", "width" | "height" | "size" => "size", + "layoutSizingHorizontal" + | "layoutSizingVertical" + | "layoutGrow" + | "layoutAlign" + | "primaryAxisSizingMode" + | "counterAxisSizingMode" + | "minWidth" + | "maxWidth" + | "minHeight" + | "maxHeight" => "resizing", "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding", "itemSpacing" | "counterAxisSpacing" => "gap", "primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment", @@ -1110,7 +1219,7 @@ fn local(parent: Affine, world: Affine) -> Affine { /// Finite path data within the coordinate cap. /// A vector network in our own JSON shape, when it's within bounds and refers only to what it has. -fn network(v: &Value) -> Option { +pub(crate) fn network(v: &Value) -> Option { use serde::Deserialize; let mut net = VectorNetwork::deserialize(v).ok()?; let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..1003682b 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -5,8 +5,8 @@ use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, I use figcraft_doc::geom::Affine; use figcraft_doc::{ AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, - ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, - StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, + ExportSize, Glass, GridAlign, GridKind, GridTrack, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, + StrokeCap, StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, }; use serde_json::Value; @@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9); let f = p.get("filters"); let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32; + let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"]; + let mut has_filters = false; + if let Some(filters) = f.and_then(Value::as_object) { + for (key, value) in filters { + match value.as_f64().filter(|value| value.is_finite()) { + Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0, + Some(0.0) => {} + Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))), + None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))), + } + } + } else if f.is_some_and(|filters| !filters.is_null()) { + cx.warn("malformed image filters container was skipped"); + } + let fig = cx.fig_sources.is_some(); + if has_filters && fig { + cx.warn("image filters use FigCraft's approximate renderer"); + } ImagePaint { image: cx.image_key(text(p, "imageRef").unwrap_or("")), scale_mode, @@ -189,7 +207,9 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - tint: filter("tint"), + // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and + // .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.) + tint: 0.0 - filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, @@ -394,7 +414,7 @@ pub(super) fn constraints(v: &Value) -> Option { // ---- auto layout ---- /// The auto layout of a frame-like node (`layoutMode` other than `NONE`). -pub(super) fn auto_layout(v: &Value) -> Option { +pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { let mode = match text(v, "layoutMode")? { "HORIZONTAL" => LayoutMode::Horizontal, "VERTICAL" => LayoutMode::Vertical, @@ -432,10 +452,31 @@ pub(super) fn auto_layout(v: &Value) -> Option { grid_rows: count("gridRowCount", 2), strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false), - ..AutoLayout::default() + column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() }, + row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() }, }) } +fn fig_grid_tracks(v: &Value, axis: &str) -> Vec { + v.get(crate::rest::FIG_SOURCE_FIELD) + .and_then(|source| source.get("gridTracks")) + .and_then(|tracks| tracks.get(axis)) + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(1_000) + .map(|track| { + let value = track.get("value").and_then(Value::as_f64).filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n)); + match (track.get("sizing").and_then(Value::as_str), value) { + (Some("fixed"), Some(px)) => GridTrack::fixed(px), + (Some("fill"), Some(fr)) if fr > 0.0 => GridTrack::fill(fr), + (Some("hug"), _) => GridTrack::hug(), + _ => GridTrack::default(), + } + }) + .collect() +} + /// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its /// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes /// and grid cells. @@ -443,8 +484,17 @@ pub(super) fn child_layout(v: &Value, own: Option, parent_mode: Opti let mut cl = ChildLayout::default(); let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical; // Legacy fields first, the explicit per-axis sizing wins. + // A text's resize mode is its sizing: auto width hugs both axes, auto height its height. + if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") { + match v.get("style").and_then(|s| text(s, "textAutoResize")) { + Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug), + Some("HEIGHT") => cl.sizing_v = Sizing::Hug, + _ => {} + } + } if let Some(m) = own { - let hug = |k: &str| text(v, k) == Some("AUTO"); + // Both default to AUTO (hug) when the file leaves them out. + let hug = |k: &str| text(v, k) != Some("FIXED"); let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode")); let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) }; if h { @@ -550,6 +600,6 @@ pub(super) fn export_settings(v: &Value) -> Vec { } /// At most 200 chars of `s` (names and labels from the file stay readable in warnings). -pub(super) fn clip_str(s: &str) -> &str { +pub(crate) fn clip_str(s: &str) -> &str { s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..1b3f85b6 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -186,6 +186,26 @@ fn paints_gradients_and_image_placeholders() { assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}"); } +#[test] +fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { + let rect = |name: &str, tint: f64| { + json!({ + "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] + }) + }; + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5), rect("Neutral", 0.0)])); + let image = |name: &str| { + let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + // The same sign as a `.fig` import (`fig::tests::image_filters`). + assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); + assert!(close(f64::from(image("Green").filters.tint), 0.5)); + assert!(image("Neutral").filters.tint.is_sign_positive(), "no negative zero"); + assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); +} + #[test] fn image_bytes_come_from_the_resolver() { let png = { @@ -363,6 +383,7 @@ fn auto_layout_constraints_and_grids() { ]}, {"id": "4", "name": "Grid", "type": "FRAME", "relativeTransform": at(200.0, 0.0), "size": {"x": 300, "y": 200}, "layoutMode": "GRID", "gridColumnCount": 3, "gridRowCount": 2, "gridColumnGap": 4, "gridRowGap": 6, + "_figSource": {"gridTracks": {"columnTracks": [{"sizing": "fixed", "value": 120}] }}, "children": [{"id": "5", "name": "Cell", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "gridColumnAnchorIndex": 1, "gridRowAnchorIndex": 0, "gridColumnSpan": 2, "gridRowSpan": 1}]} ])); @@ -389,6 +410,7 @@ fn auto_layout_constraints_and_grids() { assert_eq!((abs.constraints.horizontal, abs.constraints.vertical), (HConstraint::LeftRight, VConstraint::Bottom)); let grid = named(&doc, "Grid").layout.clone().unwrap(); assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (LayoutMode::Grid, 3, 2, 4.0, 6.0)); + assert!(grid.column_tracks.is_empty(), "private .fig track metadata is ignored by the public REST importer"); let cell = &named(&doc, "Cell").child_layout; assert_eq!((cell.grid_column, cell.grid_row, cell.grid_column_span, cell.grid_row_span), (Some(1), Some(0), 2, 1)); } @@ -707,3 +729,18 @@ fn layer_count_is_capped() { assert_eq!(doc.node_count(), MAX_NODES + 1); assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } + +#[test] +fn the_fig_import_sets_its_own_layer_cap() { + // The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in. + let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let json = file(json!([page("Page", Value::Array(kids))])); + let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap(); + assert_eq!(doc.node_count(), MAX_NODES + 6); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); + // Below it, the import stops at the cap it was given. + let few: Vec = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap(); + assert_eq!(doc.node_count(), 5); + assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 5fbb368d..367677a6 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -27,8 +27,25 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { let mut td = TextData { characters, style: base, ..TextData::default() }; paragraph(style_json, &mut td); td.runs = runs(cx, v, &td); + // Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out + // of the look (runs have inherited the base value by now). + for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) { + if st.decoration != TextDecoration::Underline && st.look().skip_ink { + st.set_look(|l| l.skip_ink = false); + } + } td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } @@ -106,6 +123,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { if let Some(ls) = num(s, "letterSpacing") { t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) }; } + // Private, from the .fig mapper: the spacing was a share of the font size. + if cx.fig_sources.is_some() + && let Some(p) = num(s, "_letterSpacingPercent") + { + t.letter_spacing = LetterSpacing::Percent(p.clamp(-1e4, 1e4)); + } match text(s, "textCase") { Some("ORIGINAL") => t.case = TextCase::Original, Some("UPPER") => t.case = TextCase::Upper, @@ -126,7 +149,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { } if let Some(h) = s.get("hyperlink") { match (text(h, "type"), text(h, "url")) { - (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()), + // The destination is data, not a label: `clip_str` is for names that go into warnings, + // and clipping a URL changed it into a different, working-looking one without a word + // (#459). The FIG importer already keeps it whole. + (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()), + // An empty URL clears the link the base style has. + (Some("URL") | None, Some(_)) => t.link = None, _ => {} } } @@ -205,10 +233,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`. +/// Each paragraph's list type, nesting level and start number from `lineTypes`, +/// `lineIndentations` and (from `.fig` files) `listStartOffsets`. fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); let indents = list(v, "lineIndentations"); + let starts = list(v, "listStartOffsets"); td.paragraphs = types .iter() .enumerate() @@ -219,6 +249,7 @@ fn lists(v: &Value, td: &mut TextData) { _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), + start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/format/tests/import_link_contract.rs b/crates/format/tests/import_link_contract.rs new file mode 100644 index 00000000..30a3408a --- /dev/null +++ b/crates/format/tests/import_link_contract.rs @@ -0,0 +1,57 @@ +//! A text hyperlink's destination survives a REST import whole, however long it is (#459). +//! +//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so +//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function. + +use figcraft_doc::NodeKind; +use figcraft_format::rest; +use serde_json::json; + +/// The link of the imported text, and the warnings the import reported. `None` for a link means +/// the layer was missing, was not text, or carried no link at all. +fn imported_link(length: usize, override_style: bool) -> (String, Option, Vec) { + let url = format!("https://example.com/{}", "a".repeat(length - 20)); + assert_eq!(url.len(), length); + let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]}); + let style = json!({"hyperlink":{"type":"URL","url":url}}); + if override_style { + text["characterStyleOverrides"] = json!([1]); + text["styleOverrideTable"] = json!({"1":style}); + } else { + text["style"] = style; + } + let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}}); + assert!(rest::is_rest_json(&input)); + let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else { + return (url, None, vec!["import failed".into()]); + }; + let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) { + Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()), + Some(NodeKind::Text(data)) => data.style.link.clone(), + _ => None, + }; + (url, link, warnings) +} + +/// Every length in turn: the destination comes back exactly, and nothing is reported. +fn check(override_style: bool) { + let mut mismatches = Vec::new(); + for length in [200, 201, 240] { + let (url, link, warnings) = imported_link(length, override_style); + assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}"); + if link.as_deref() != Some(url.as_str()) { + mismatches.push((length, link.map_or(0, |l| l.len()))); + } + } + assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}"); +} + +#[test] +fn base_hyperlink_preserves_url() { + check(false); +} + +#[test] +fn override_hyperlink_preserves_url() { + check(true); +} diff --git a/crates/format/tests/range_hyperlink_round_trip.rs b/crates/format/tests/range_hyperlink_round_trip.rs new file mode 100644 index 00000000..f47e22ae --- /dev/null +++ b/crates/format/tests/range_hyperlink_round_trip.rs @@ -0,0 +1,38 @@ +//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import. + +use figcraft_doc::{Document, NodeKind, TextData}; +use figcraft_format::fig; + +fn links(doc: &Document, name: &str) -> Vec> { + let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() }; + text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect() +} + +#[test] +fn cleared_range_hyperlink_round_trip() { + let mut document = Document::new("File"); + let page = document.first_page().unwrap(); + let url = "https://example.com/".to_string(); + + let mut text = TextData { characters: "AB".into(), ..Default::default() }; + text.style.link = Some(url.clone()); + text.restyle(1, 2, |style| style.link = None); + document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let mut control = TextData { characters: "AB".into(), ..Default::default() }; + control.restyle(1, 2, |style| style.link = Some(url.clone())); + document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap(); + + let expected = vec![Some(url.clone()), None]; + assert_eq!(links(&document, "Cleared"), expected); + let native_bytes = figcraft_format::save_bytes(&document, None).unwrap(); + let native = figcraft_format::load_bytes(&native_bytes).unwrap().0; + assert_eq!(links(&native, "Cleared"), expected); + + let (bytes, export_warnings) = fig::export(&document, None).unwrap(); + let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap(); + assert!(export_warnings.is_empty(), "{export_warnings:?}"); + assert!(import_warnings.is_empty(), "{import_warnings:?}"); + assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]); + assert_eq!(links(&loaded, "Cleared"), expected); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 16f1b556..79ef4945 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec { tool( "save_file", "Save file", - "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.", - obj(json!({"path": string("Output .figcraft path")}), &[]), + "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.", + obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]), false, ), tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false), @@ -669,8 +669,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map) -> Result