diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 9b7503bc..e60c2bdf 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -23,38 +23,7 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # The Japanese font tests run against craft-fonts (an optional build input; they skip without it). - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - 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 - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL -o /tmp/rustup-init \ - https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init - test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" - chmod 700 /tmp/rustup-init - /tmp/rustup-init -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 03fddb96..f54b0538 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,54 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - archive="$RUNNER_TEMP/actionlint.tar.gz" - curl -fsSL -o "$archive" \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c - - tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/designcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.designcraft" and m["command"] == "designcraft", 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 - - name: FreeBSD package layout (dry run) - run: bash packaging/freebsd/package.sh --dry-run + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbd8acf3..57eb9bb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "DesignCraft v". See docs/releasing.md. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is -# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The jobs -# that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no secrets, -# so a `workflow_dispatch` on another branch dry-runs them; the signing jobs are refused there, -# which skips the draft release. name: Release on: @@ -20,420 +11,33 @@ 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 - DESIGNCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: no secrets needed, and branch dry runs need this job. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### DesignCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - runner: ubuntu-22.04 - deb: amd64 - nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97 - - arch: aarch64 - runner: ubuntu-22.04-arm - deb: arm64 - nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97 - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - NFPM_SHA256: ${{ matrix.nfpm_sha256 }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c - - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_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/designcraft$|metainfo|applications' - ldd target/release/designcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.designcraft.yml')); assert m['id'] == 'ai.storyteller.designcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - 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. - runs-on: ubuntu-latest - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - 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 # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "DESIGNCRAFT_VERSION DESIGNCRAFT_BUILD_DATE DESIGNCRAFT_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 alsa-lib - curl -fsSL -o /tmp/rustup-init \ - https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init - test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" - chmod 700 /tmp/rustup-init - /tmp/rustup-init -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/designcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$DESIGNCRAFT_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/designcraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install trunk - run: | - archive="$RUNNER_TEMP/trunk.tar.gz" - curl -fsSL -o "$archive" \ - "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" - printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c - - tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="DesignCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: designcraft + app-title: DesignCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + 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 b568d4e7..ed7060b9 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,70 +26,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: designcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: designcraft-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 'DesignCraft' - foreach ($exe in 'designcraft.exe', 'designcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'designcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "designcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok designcraft-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 'designcraft.exe')) { throw 'uninstall left designcraft.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: designcraft + app-dir: DesignCraft diff --git a/AGENTS.md b/AGENTS.md index bedbfb6e..65693656 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index dfe78adb..e8c455b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,4 +38,4 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. diff --git a/Cargo.lock b/Cargo.lock index c2af9873..6e9343d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,23 @@ dependencies = [ "libm", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -870,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -887,7 +904,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -903,7 +920,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.5.0" +version = "0.7.1" dependencies = [ "kurbo", "moxcms", @@ -915,7 +932,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -932,7 +949,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-geom", @@ -944,9 +961,10 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.5.0" +version = "0.7.1" dependencies = [ "caseless", + "craft-print", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -978,7 +996,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -987,7 +1005,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.5.0" +version = "0.7.1" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -999,7 +1017,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "serde", @@ -1010,7 +1028,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.5.0" +version = "0.7.1" dependencies = [ "flo_curves", "kurbo", @@ -1023,7 +1041,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1036,7 +1054,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1047,7 +1065,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1060,7 +1078,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1099,7 +1117,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1117,7 +1135,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1126,7 +1144,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1138,8 +1156,9 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.5.0" +version = "0.7.1" dependencies = [ + "craft-ui", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1155,15 +1174,17 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "toml", "unicode-bidi", "unicode-bidi-mirroring", "unicode-segmentation", + "zip", ] [[package]] name = "designcraft-web" -version = "0.5.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-format", @@ -1480,7 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2654,7 +2675,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2737,7 +2758,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3846,7 +3867,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4261,7 +4282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4496,7 +4517,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5233,7 +5254,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5514,8 +5535,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", @@ -5683,7 +5703,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.5.0" +version = "0.7.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4922a771..c528eec4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -68,6 +69,8 @@ quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" egui_extras = { version = "0.36", default-features = false, features = ["svg"] } eframe = { version = "0.36", default-features = false } @@ -95,3 +98,7 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Opt-in cancelable macOS termination on the pinned winit 0.30 line. +[patch.crates-io] +winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/NOTICE b/NOTICE index 8b67a99e..087bb619 100644 --- a/NOTICE +++ b/NOTICE @@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md. AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"): + hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, + 2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence: + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions in + binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. * Neither + the name of the University of Campinas, of the University of Minho nor the names of its + contributors may be used to endorse or promote products derived from this software without + specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/README.md b/README.md index 84dc861f..efb05dcc 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,15 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or ## Quick start +The egui frontend requires Rust 1.95 or newer (including its shared `craft-ui` components). + ```sh cargo run --release -p designcraft # desktop app (start screen) cargo run --release -p designcraft -- --sample # open the sample magazine cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG cargo run --release -p designcraft-cli -- commands # list every command +cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md) cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` @@ -184,7 +187,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the | L6 | `ui-egui` (InDesign-style UI, control channel) | | L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | -- **Status and milestones:** [ROADMAP.md](ROADMAP.md) +- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) - **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md) - **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md) - **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md) @@ -222,6 +225,8 @@ Installers and executables are code-signed. | Fedora/RHEL/openSUSE | `designcraft--linux-x86_64.rpm` | `designcraft--linux-aarch64.rpm` | | | Tarball | `designcraft--linux-x86_64.tar.gz` | `designcraft--linux-aarch64.tar.gz` | Unpack anywhere | +RISC-V (riscv64): `designcraft--linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer). + ### FreeBSD | Build | File | diff --git a/ROADMAP.md b/ROADMAP.md index 791568c2..8a43cf8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,19 +211,204 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( ## Milestones | # | Milestone | State | +**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 + +DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON +format + IDML), scriptable by agents (MCP), and available on the web. + +## Headline numbers + +| | Value | Kind | |---|---|---| -| M0 | Skeleton + vertical slice | ✅ | -| M1 | Selection, transform, layers, pages, MCP | in progress | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | in progress | -| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started | -| M4 | Color & effects | started | -| M5 | Graphics & links | started | -| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) | -| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) | -| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | -| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) | -| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | -| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) | -| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | -| M13 | Automation (scripts, batch) | | -| M14 | 1.0 polish & packaging | | +| **Feature breadth** | **~80%** (69% of InDesign 21.6's in-scope menu items matched; 99% of our 275-row checklist) | measured bounds, estimate between | +| **Ready for real work** | **~47%** (range 42–52%) | estimated: weighted sum over the dimensions below | +| Mainstream practitioner | **~42%** (weekly core 60.7% × 0.88 interaction × 0.92 stability × 0.85 file exchange) | estimated | +| Essentials user | **~54%** (core features 72% × 0.90 launch × 0.88 discoverability × 0.95 files received) | estimated | +| Remaining to **beta** | **420–750 Opus 5.5 agent-hours** (~120–200 wall-clock hours with 4–5 agents) | estimated | +| Remaining to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | + +| Audience | Ready | Opus 5.5 agent-hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~47% | 1,150–1,950 (~70% parallel) | depth across all areas, scripting compatibility, localization, typography fidelity, INDD/IDML | +| Mainstream practitioner | ~42% | 500–850 (~65% parallel) | INDD import and IDML corpus, composition fidelity, weekly-area depth, PDF/X-1a and printing | +| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing | + +**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in +[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to +another: the in and out ports are drawn, but clicking the out port does nothing and there is no +thread or unthread command. Long text flows only through autoflow on Place and Primary Text +Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything +else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF +export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual +threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on +real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux. +Details: [docs/target-app-parity.md](docs/target-app-parity.md). + +## By dimension + +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (depth) | 55% | 250–400 | [target-app-parity.md](docs/target-app-parity.md) | +| Typography and composition fidelity | 45% | 120–200 | [typography-parity.md](docs/typography-parity.md) | +| File formats | 40% | 150–250 + 20–60 (INDD) | [file-format-parity.md](docs/file-format-parity.md) | +| UI/UX fidelity | 45% | 120–180 | [ui-parity.md](docs/ui-parity.md) | +| Stability | 45% | 60–100 | [gaps.md](docs/gaps.md) | +| Performance | 65% | 40–80 | [gaps.md](docs/gaps.md) | +| Localization | 45% | 120–180 | [localization-parity.md](docs/localization-parity.md) | +| Platforms | 55% | 40–80 | [hardware-parity.md](docs/hardware-parity.md) | +| Hardware | 45% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) | +| Ecosystem (scripting, plug-ins) | 15% | 150–250 | [gaps.md](docs/gaps.md) | +| AI features | 15% | 60–120 | [gaps.md](docs/gaps.md) | + +All "Ready" figures are estimates; the weights and evidence are in target-app-parity.md. + +## Features + +Breadth from [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity`, +measured); Ready estimated. + +| Area | Breadth | Ready | Hours | +|---|---|---|---| +| Application shell & workspace | 99% | 55% | 40–60 | +| Documents, pages, spreads | 100% | 65% | 20–35 | +| Layers | 100% | 75% | 5–10 | +| Frames, shapes & paths | 100% | 60% | 25–40 | +| Transform | 100% | 70% | 10–20 | +| Fill, stroke, colour | 100% | 60% | 20–35 | +| Effects & transparency | 100% | 50% | 20–30 | +| Placing & links | 93% | 55% | 20–35 | +| Type & text frames | 99% | 45% | 40–60 | +| Typography | 100% | 45% | see typography-parity.md | +| Styles | 100% | 60% | 15–25 | +| Tables | 100% | 55% | 25–40 | +| Long documents | 100% | 45% | 30–50 | +| Interactivity & digital | 100% | 40% | 30–50 | +| Output & production | 92% | 55% | 40–70 | +| XML & automation | 100% | 35% | see Ecosystem | +| View & navigation | 100% | 65% | 10–20 | +| Undo, history, saving | 100% | 70% | 5–10 | +| Accessibility | 100% | 50% | 15–25 | + +## Languages + +Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md). + +| Language | Code | Status | Translated | +|---|---|---|---| +| English | en | full | 100% | +| Simplified Chinese | zh | partial (no canvas IME) | 83% | +| Spanish | es | partial | 83% | +| Hindi | hi | none | 0% | +| Arabic | ar | partial (RTL UI and composition) | 85% | +| French | fr | partial | 83% | +| Portuguese (Brazil) | pt-br | partial | 61% | +| Indonesian | id | none | 0% | +| Japanese | ja | partial (no canvas IME) | 95% | +| German | de | partial | 83% | +| Korean | ko | none | 0% | +| Vietnamese | vi | none | 0% | + +Also shipped: Italian (85%), Ukrainian (83%). + +## Upcoming + +Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in +[docs/gaps.md](docs/gaps.md). + +1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h) +2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h) +3. CI running `cargo xtask ci` on pull requests (4–8 h) +4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h) +5. IDML real-file corpus and fixes (60–100 h) +6. Composition oracle against InDesign; optical kerning; overset rule (80–140 h) +7. Start on any GPU; find every installed font (25–50 h) + +## Documents + +[target-app-parity.md](docs/target-app-parity.md) (assessment) · [gaps.md](docs/gaps.md) (work +list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.md](docs/parity-checklist.md) +(presence checklist) · [typography-parity.md](docs/typography-parity.md) · +[file-format-parity.md](docs/file-format-parity.md) · [ui-parity.md](docs/ui-parity.md) · +[hardware-parity.md](docs/hardware-parity.md) · [localization-parity.md](docs/localization-parity.md) · +[architecture.md](docs/architecture.md) · agents: [agents.md](docs/agents.md), [mcp.md](docs/mcp.md), +[control-protocol.md](docs/control-protocol.md) · CJK: [cjk.md](docs/cjk.md), +[cjk-typography.md](docs/cjk-typography.md) · Arabic: [arabic-typography.md](docs/arabic-typography.md) + +## Progress log + +Newest first. + +- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. +- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. +- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. +- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. +- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. +- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. +- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. +- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. +- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. +- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. +- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. +- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. +- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. +- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. +- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. +- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. +- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. +- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. +- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. +- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. +- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. + +- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. +- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI + smoke-tested under QEMU). +- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was + 42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%. +- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text + can't be threaded by hand (no out-port click, no thread command). +- **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and + localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of + 2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules + standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap, + architecture, typography, file-format, UI, hardware and localization docs). Landed today: + split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules + dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish + hyphenation (#222), Japanese (#272), Italian (#218) and Ukrainian (#236) interfaces, relocated packages relink (#244), graphics backend chosen + before the window (#297), IDML table styles (#288), warichu (#280), list numbering from IDML + (#298), security hardening (#153, #155), many text-composition fixes (#282–#293). +- **2026-10-09** · Version 0.5.0; many user reports triaged into issues (#160–#265). +- **2026-10-08** · Version 0.4.0. +- **2026-10-07** · Community PRs: data merge, Brazilian Portuguese, PDF/X-4 flags, Office fonts + on macOS; CJK 1.x (Korean line breaking, fallback fonts in Package); PrintCraft renamed PdfCraft. +- **2026-10-06** · Snapping to parent items, Windows packaging, ARM64 workflow, control-channel + hardening. +- **2026-10-05** · FreeBSD build; caret-boundary fixes. +- **2026-10-04** · Never-crash pass across importers and the engine; rotate spread view; kashida + in styles; previous estimate: breadth 99%, "~83% including depth", ~170 h remaining. +- **2026-10-03** · Release 0.1.1; separations, XML tagging, track changes, PDF/X-4 output + intent, split window, buttons, menu customization. +- **2026-10-02** · Hyperlinks/Bookmarks panels, conditional text, tagged PDF, lists across + stories, style groups, Glyphs panel, High Contrast theme. +- **2026-10-01** · First commit; skeleton, IDML, PDF, tables, footnotes, cross-references, index, + spelling and parallel composition on the first day. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | +| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | +| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) | +| 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h | +| 2026-10-01 | major | First roadmap: status, milestones | diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 108dd7a7..8d3e2262 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -39,6 +39,37 @@ use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. +const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] + +Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with +--connect PORT, in the running app (designcraft --control PORT). + +Script text, one step per line: + command.id {"param": 1} also `command.id={json}`, or `command.id` alone + {"command": "command.id", "params": {...}} JSON lines, or a JSON array of them + # comment, // comment skipped, like blank lines + UTF-8 with or without a byte order mark; LF or CRLF line endings. + +References to earlier results (in string parameters): + "$N.path" the value at `path` in the result of step N. N is the 0-based step index: + comments and blank lines are not steps. `$last` is the previous step. + Paths go through result fields and array indices: "$1.story", "$3.ids.0". + "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + +Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +Most commands ignore parameters they do not know instead of rejecting them: check names +with `designcraft-cli describe COMMAND`. + +Options: + --in FILE / --sample open a document (.designcraft or .idml) or the sample first + --save OUT save as .designcraft after the script succeeds + --export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable + --keep-going run every step, recording failures as {"error": ...} results + +Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a +non-zero exit when a step fails."#; + /// The links line under the usage block. fn usage_footer() -> String { format!( @@ -290,7 +321,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }) .ok_or("no such page")?; let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs); Ok(()) } @@ -344,6 +375,10 @@ fn script(args: &[String]) -> Result<(), String> { "--save" => save = Some(val()?), "--export" => export.push(val()?), "--keep-going" => keep_going = true, + "--help" | "-h" => { + outln!("{SCRIPT_HELP}"); + return Ok(()); + } f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 1a06d13b..92f68ab5 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() { assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); } +/// `script --help` was "unknown script option `--help`"; the line syntax was only in +/// `describe script.run` (#654). +#[test] +fn script_help_documents_the_syntax() { + for flag in ["--help", "-h"] { + let out = cli().args(["script", flag]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}"); + for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] { + assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}"); + } + } +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] @@ -122,3 +137,35 @@ fn run_export_options_come_before_their_export() { assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "CLI export changed bytes under an existing reader"); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index 75775c31..b1131c79 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -3,6 +3,7 @@ name = "designcraft-web" description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 416b0740..b65efbe4 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false default-run = "designcraft" @@ -30,4 +31,6 @@ rfd = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] +# The same winit as eframe; only the native termination opt-in is used. +winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index e80f566b..6dfb0236 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -367,7 +367,9 @@ mod tests { // out of the set once it failed. write(&path, &record(Some(Backend::Gl), &[])).expect("write"); let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); - if candidates().len() > 1 { + // This synthetic prior start records OpenGL even on Metal-only macOS. + // Switching depends on the chosen backend, not the candidate count. + if s.backend != Backend::Gl { assert_eq!(s.switched_from, Some(Backend::Gl)); let line = s.status_line().expect("status"); assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index c3060623..26ce2ee4 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -16,6 +16,7 @@ mod gpu; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod profile; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; @@ -63,22 +64,31 @@ impl eframe::App for App { if let Some(s) = self.startup.as_mut() { s.presented(); } + if let Err(e) = preserve_documents_on_exit(&mut self.app) { + log::error!("Couldn't preserve documents on exit: {e}"); + eprintln!("designcraft: couldn't preserve documents on exit: {e}"); + } save_prefs(&self.app); } } -/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here. -fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("designcraft")) +/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete, +/// current quit plan; otherwise preserve every live document, including edits after the timer. +fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> { + app.finish_confirmed_quit()?; + if app.session.documents().is_empty() { + return Ok(()); } + if app.session.recovery_dir.is_none() { + return if app.session.documents().iter().any(|d| d.is_dirty()) { + Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into()) + } else { + Ok(()) + }; + } + // Also discard stale recovery for clean documents (for example, Undo back to the saved + // state after the last timer tick). Never choose a fallback recovery path here. + app.run("file.recovery.save", serde_json::json!({})).map(|_| ()) } /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). @@ -87,7 +97,7 @@ fn no_prefs() -> bool { } fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) + profile::config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { @@ -96,7 +106,7 @@ fn load_prefs(app: &mut DesignApp) { } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) + && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { app.ui = ui; } @@ -199,7 +209,7 @@ fn services() -> Services { })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), ..Default::default() } } @@ -249,7 +259,7 @@ fn main() -> eframe::Result { } // The log file lives under the settings directory; opened after the arguments, so `--version` // leaves no file behind. Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, config_dir()) { + if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) { match logger.attach_dir(&dir.join("logs")) { Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. @@ -273,7 +283,7 @@ fn main() -> eframe::Result { } // The graphics backend, decided before the window exists: a driver that faults takes the // process down before any Rust code can catch it (see `gpu`). - let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); + let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); if let Some(s) = &gpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { @@ -289,6 +299,15 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through + // ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow. + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|builder| { + use winit::platform::macos::EventLoopBuilderExtMacOS; + builder.with_close_requested_on_terminate(true); + })); + } eframe::run_native( "DesignCraft", options, @@ -304,7 +323,7 @@ fn main() -> eframe::Result { } let mut session = Session::new(); // Crash recovery: reopen what a previous run left unsaved, then keep it current. - session.recovery_dir = designcraft_engine::recovery::default_dir(); + session.recovery_dir = profile::recovery_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { @@ -366,3 +385,168 @@ mod tests { assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); } } + +#[cfg(all(test, unix))] +mod atomic_writer_tests { + #[test] + fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let mut write = super::services().write.unwrap(); + write(alias.to_str().unwrap(), b"complete new export").unwrap(); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "desktop export changed bytes under an existing reader"); + assert_eq!(std::fs::read(&target).unwrap(), b"complete new export"); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); + } +} + +#[cfg(test)] +mod exit_recovery_tests { + use super::*; + use serde_json::json; + + fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id())); + assert!(!dir.exists()); + app.session.recovery_dir = Some(dir.clone()); + (app, dir) + } + + fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() { + let (mut app, dir) = dirty_app("latest"); + app.run("file.recovery.save", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + let d = app.session.active().unwrap(); + let (uid, revision, saved_revision, doc, selection, undo, redo) = + (d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()); + preserve_documents_on_exit(&mut app).unwrap(); + let current = app.session.active().unwrap(); + assert_eq!( + (current.uid, current.revision, current.saved_revision, ¤t.selection.items, current.history.undo.len(), current.history.redo.len()), + (uid, revision, saved_revision, &selection, undo, redo) + ); + assert!(std::sync::Arc::ptr_eq(¤t.doc, &doc)); + assert!(current.is_dirty()); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_removes_stale_data_after_undo_to_saved_state() { + let (mut app, dir) = dirty_app("clean"); + let uid = app.session.active().unwrap().uid; + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + assert!(entry.exists()); + app.run("edit.undo", json!({})).unwrap(); + assert!(!app.session.active().unwrap().is_dirty()); + let doc = app.session.active().unwrap().doc.clone(); + preserve_documents_on_exit(&mut app).unwrap(); + assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() { + let (mut app, dir) = dirty_app("failure"); + let doc = app.session.active().unwrap().doc.clone(); + app.session.recovery_dir = None; + assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable")); + std::fs::create_dir(&dir).unwrap(); + let blocked = dir.join("not-a-directory"); + std::fs::write(&blocked, b"preserve this file").unwrap(); + app.session.recovery_dir = Some(blocked.clone()); + assert!(preserve_documents_on_exit(&mut app).is_err()); + assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert!(app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.documents().len(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() { + for case in ["complete", "incomplete", "stale", "new"] { + let (mut app, dir) = dirty_app(case); + if case == "incomplete" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + app.run("file.recovery.save", json!({})).unwrap(); + let first_uid = app.session.documents()[0].uid; + let ctx = egui::Context::default(); + close_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + designcraft_ui_egui::dialogs::confirm(&mut app).unwrap(); + close_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + preserve_documents_on_exit(&mut app).unwrap(); + if case == "complete" { + assert!(app.session.documents().is_empty()); + assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered"); + } else { + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 8bf75b15..f51db197 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -40,6 +40,8 @@ impl NativeMenu { // Our own About splash (with the community links) instead of the stock panel. let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None); let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None); + // Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt. + let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &discord, @@ -50,10 +52,11 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &quit, ]); items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about))); items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord))); + items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit))); let _ = menu.append(&app_menu); for (title, entries) in menus::menu_tree() { let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true); diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs new file mode 100644 index 00000000..3e90879e --- /dev/null +++ b/apps/designcraft/src/profile.rs @@ -0,0 +1,108 @@ +//! Native settings and recovery paths, including isolated automation profiles. + +use std::ffi::OsString; +use std::path::PathBuf; + +pub fn config_dir() -> Option { + config_dir_with(std::env::consts::OS, |key| std::env::var_os(key)) +} + +fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option { + if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") { + // An explicitly empty override must not fall back to the user's profile. + return (!root.is_empty()).then(|| PathBuf::from(root)); + } + match os { + "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support/DesignCraft")), + "windows" => env("APPDATA").map(|p| PathBuf::from(p).join("DesignCraft")), + _ => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join("designcraft")) + } + } +} + +pub fn recovery_dir() -> Option { + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run) +} + +/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so +/// neither writes over the other's documents nor takes them for a crash's. +fn own_run(root: PathBuf) -> Option { + match designcraft_engine::recovery::run_dir(&root) { + Ok(dir) => Some(dir), + Err(e) => { + log::warn!("recovery is unavailable because this run cannot be isolated: {e}"); + None + } + } +} + +fn recovery_dir_with(root: Option, default: impl FnOnce() -> Option) -> Option { + match root { + Some(root) => (!root.is_empty()).then(|| PathBuf::from(root).join("Recovery")), + None => default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn environment(key: &str) -> Option { + match key { + "HOME" => Some("/user".into()), + "APPDATA" => Some("/roaming".into()), + "XDG_CONFIG_HOME" => Some("/xdg-config".into()), + _ => None, + } + } + + #[test] + fn isolated_profile_overrides_every_platform_and_recovery() { + for os in ["macos", "windows", "linux"] { + let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }); + assert_eq!(dir, Some(PathBuf::from("/isolated"))); + } + assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery"))); + } + + #[test] + fn empty_override_never_reads_or_writes_the_default_profile() { + assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None); + assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None); + } + + #[test] + fn unset_override_preserves_existing_platform_paths() { + assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into())); + assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into())); + assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into())); + assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); + assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); + } + + #[test] + fn a_run_has_its_own_recovery_folder_when_one_can_be_made() { + let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("Recovery"); + assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path())); + // Failure must not fall back to the shared root. + let blocked = dir.join("blocked"); + std::fs::write(&blocked, "a file where the folder should be").unwrap(); + assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() { + let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + for n in 0..64 { + std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap(); + } + assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions"); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5e30a98..4da4f2d8 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -104,9 +104,12 @@ impl Gradient { out } pub fn reverse(&mut self) { + // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. + let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); self.stops.reverse(); - for s in &mut self.stops { + for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) { s.offset = 1.0 - s.offset; + s.midpoint = m; } } pub fn sort(&mut self) { @@ -219,8 +222,24 @@ mod tests { #[test] fn reverse_stops() { let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; g.reverse(); assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); + // The midpoint moves to its reversed segment and mirrors. + assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::>(), [0.75, 0.5]); + + // With several stops, the reversed gradient is the original mirrored. + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.3; + g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 }); + let original = g.clone(); + g.reverse(); + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t)); + assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}"); + assert!((o - want_o).abs() < 1e-5, "opacity at {t}"); + } } } diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 01286883..b8993bcf 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b98..8aabbf0f 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode}; use crate::vars::RunningIndex; use crate::{ComposeOptions, ComposedStory}; -type Key = (StoryId, Option); +/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`). +type Key = (StoryId, Option); struct Entry { sig: Vec, @@ -55,11 +56,23 @@ impl Cache { Self::default() } - /// The composed story, from cache or freshly composed. - pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc { + /// The composed story, from cache or freshly composed. `page` is the absolute page a + /// parent-page item is shown on (its page number markers and variables are that page's), or + /// `None` for a story's own frames. Pages are identified by index, not name: names repeat when + /// sections restart their numbering. + pub fn get(&self, doc: &Document, sid: StoryId, page: Option) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, keep_items) = signature(doc, story); - let key = (sid, page_name.map(str::to_string)); + let key = (sid, page); + // The key holds the page's index, which a renumbering doesn't change: its name (section + // start number, style, prefix) is part of the signature. + let page_name = page.map(|p| doc.page_name(p)); + if let Some(name) = &page_name { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut h); + sig.push(h.finish() as usize); + } let with_vars = crate::vars::has_vars(story); let running = with_vars && crate::vars::has_running(doc, story); if with_vars { @@ -125,23 +138,20 @@ impl Cache { return e.out.clone(); } } - // Parent-page items are composed per page; variables need that page's index. - let page = match page_name { - Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n), - _ => None, - }; + // Parent-page items are composed per page: markers show its name, variables need its index. + let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); } - let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); drop(g); if key.1.is_none() && let Some(old) = replaced diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index 22644eb7..e3f4a43b 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,6 +1,7 @@ //! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's -//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE). +//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. +//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE). //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -232,9 +233,8 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { if letters < lim.min_word.max(2) { return false; } - if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { - return false; - } + // Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate + // Capitalized Words off keeps both, acronyms included, whole. if !lim.capitalized && run[0].is_uppercase() { return false; } @@ -332,7 +332,10 @@ mod tests { #[test] fn case_apostrophes_and_punctuation() { assert_eq!(h("Typography"), "Ty-pog-ra-phy"); - assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY"); + assert_eq!(h("TYPOGRAPHY"), "TY-POG-RA-PHY"); + assert_eq!(h("COMMODI"), "COM-MO-DI"); + let whole = Limits { capitalized: false, ..Limits::default() }; + assert_eq!(hyphenate_word("TYPOGRAPHY", &whole), "TYPOGRAPHY", "Hyphenate Capitalized Words off"); assert_eq!(h("typography,"), "ty-pog-ra-phy,"); assert_eq!(h("“typography”"), "“ty-pog-ra-phy”"); assert_eq!(h("typography's"), "ty-pog-ra-phy's"); @@ -389,6 +392,25 @@ mod tests { assert!(Lang::for_language("French").is_none(), "only languages built in"); } + #[test] + fn portuguese_uses_the_hyph_pt_patterns() { + let pt = Lang::for_language("Portuguese: Brazilian").unwrap(); + assert_eq!(pt, Lang::for_language("Portuguese").unwrap()); + assert_eq!(pt, Lang::for_language("pt_BR").unwrap()); + assert_ne!(pt, Lang::for_language("Spanish").unwrap()); + // hyph-pt's own minimum: three letters after the last break. + let lim = Limits { before_last: 3, ..Limits::default() }; + let h = |w: &str| { + let pts = hyphen_points_in(w, &lim, pt); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("matemática"), "ma-te-má-tica"); + assert_eq!(h("orientação"), "ori-en-ta-ção"); + assert_eq!(h("Universidade"), "Uni-ver-si-dade"); + assert_eq!(h("experiência"), "ex-pe-ri-ên-cia"); + assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b..cd0e7cbd 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { path: None, ..tf.options.clone() }; - (Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts) + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), }; @@ -1477,7 +1478,9 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight, hyphen_limit: pp.hyph_limit, ragged_stretch: base_size * 2.0, - hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, + // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the + // Paragraph Composer weighs every break of the paragraph instead. + hyph_zone: if pp.align.is_justified() || pp.composer != Composer::SingleLine { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, } @@ -2229,6 +2232,18 @@ fn layout_line( // A default tab stop is a left tab. last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); } else if line[i].ch == story::RIGHT_INDENT_TAB { + // Use the first stop past the right margin, or the last stop at/before it. + // Choose the stop before checking its leader: an empty leader takes precedence too. + let right = x1 - tab_origin; + let stop = pp + .tabs + .iter() + .filter(|t| t.position > right) + .min_by(|a, b| a.position.total_cmp(&b.position)) + .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); + if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { + leaders.push((i, t.leader.clone())); + } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); line[i].adv = (measure - x - rest).max(0.0); last_tab = Some((i, false)); @@ -3165,6 +3180,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { #[cfg(test)] mod tests; +/// Type on a path: between the composed line (x along it, y as composed) and the frame's path +/// space, as `path_glyphs` places the glyphs. +pub struct PathTextMap { + warp: designcraft_geom::warp::PathWarp, + start: f64, + /// Line y that sits on the path (the alignment), and the line's baseline. + on: f64, + baseline: f64, +} + +impl PathTextMap { + /// The map of `item` (type on a path) for its composed frame `ft`; None for other frames. + pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option { + let pt = item.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip); + let (on, baseline) = match ft.lines.first() { + Some(l) => ( + match pt.align { + designcraft_doc::PathAlign::Baseline => l.baseline, + designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent, + designcraft_doc::PathAlign::Descender => l.baseline + l.descent, + designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0, + }, + l.baseline, + ), + None => (0.0, 0.0), + }; + Some(PathTextMap { warp, start: pt.start, on, baseline }) + } + + /// A point of the composed line, on the path: along it by x, across it by y. + pub fn to_path(&self, p: Point) -> Point { + match self.warp.at(self.start + p.x) { + Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on), + None => p, + } + } + + /// A point of the path space, on the composed line: how far along the path its nearest point + /// is, at the baseline (for hit testing). + pub fn to_line(&self, p: Point) -> Point { + Point::new(self.warp.nearest(p) - self.start, self.baseline) + } +} + /// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space /// (grouped by run style). pub fn path_glyphs( diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d2..7a0e790d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -288,11 +288,16 @@ fn hyphenation_zone_limits_ragged_hyphens() { let cs = compose_story(&d, sid, &ComposeOptions::default()); all_lines(&cs).iter().filter(|l| l.hyphenated).count() }; - for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { - let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); - // A huge zone leaves only words that start a line (no space to break at) hyphenable. - assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); - } + // Single-line Composer: a huge zone leaves only words that start a line (no space to break + // at) hyphenable. + let c = designcraft_doc::Composer::SingleLine; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); + // The zone is the Single-line Composer's only (InDesign): the Paragraph Composer hyphenates + // the same whatever it is. + let c = designcraft_doc::Composer::Paragraph; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } #[test] @@ -789,6 +794,125 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { + let text = "Intro\u{e006}12"; + let cases = [ + ("last interior", 0.0, vec![(200.0, "."), (80.0, "-")], Some('.')), + ("first exterior", 0.0, vec![(380.0, "+"), (200.0, "-"), (320.0, ".")], Some('.')), + ("at the margin", 0.0, vec![(300.0, "."), (80.0, "-")], Some('.')), + ("no stops", 0.0, vec![], None), + ("empty exterior", 0.0, vec![(200.0, "."), (320.0, ""), (350.0, "+")], None), + ("empty interior", 0.0, vec![(80.0, "."), (200.0, "")], None), + ("indented margin", 36.0, vec![(260.0, "-"), (280.0, "."), (340.0, "+")], Some('.')), + ]; + for (name, right_indent, stops, expected) in cases { + let tabs = stops + .into_iter() + .map(|(position, leader)| designcraft_doc::TabStop { position, align: TabAlign::Right, leader: leader.into(), align_on: String::new() }) + .collect(); + let para = ParaAttrs { tabs: Some(tabs), left_indent: Some(12.0), right_indent: Some(right_indent), ..Default::default() }; + // Translation and left/right indents distinguish the tab origin from line/absolute x. + let (mut d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset(), "{name}"); + assert_eq!(cs.frames[0].lines.len(), 1, "{name}"); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert_eq!(!leaders.is_empty(), expected.is_some(), "{name}"); + for g in leaders { + assert_eq!(g.gid, g.face.get().glyph_for(expected.unwrap()), "{name}"); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01, "{name}: leader outside tab gap"); + assert_eq!(g.byte, tab.byte, "{name}"); + } + assert!((line.end_x - (372.0 - right_indent)).abs() < 0.01, "{name}: number stays at the right margin"); + // Removing only the leaders must leave every source glyph and the line itself unchanged. + for t in d.story_mut(sid).unwrap().paras[0].para.tabs.as_mut().unwrap() { + t.leader.clear(); + } + let plain = compose_story(&d, sid, &Default::default()); + assert_eq!(plain.frames[0].lines.len(), 1, "{name}"); + let plain_line = &plain.frames[0].lines[0]; + assert_eq!((&line.range, line.baseline, line.end_x), (&plain_line.range, plain_line.baseline, plain_line.end_x), "{name}"); + let source_positions = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len, g.gid, g.x, g.y, g.adv, g.sx, g.sy, g.style)).collect::>() + }; + assert_eq!(source_positions(line), source_positions(plain_line), "{name}"); + assert_eq!(d.stories[&sid].text, text, "{name}: source text is not expanded"); + } +} + +#[test] +fn right_indent_tab_leaders_use_the_tab_format_and_regular_tab_drawing() { + let mut rendered = Vec::new(); + for ch in [story::RIGHT_INDENT_TAB, '\t'] { + let text = format!("Intro{ch}12"); + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 300.0, align: TabAlign::Right, leader: ".-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (mut d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + d.story_mut(sid).unwrap().format_chars(5..5 + ch.len_utf8(), |f| { + f.over.font_family = Some("Inter".into()); + f.over.size = Some(18.0); + f.over.h_scale = Some(1.3); + f.over.v_scale = Some(0.8); + f.over.baseline_shift = Some(4.0); + f.over.fill_tint = Some(0.4); + }); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let prefix = &line.glyphs[0]; + assert_ne!(tab.face.id(), prefix.face.id(), "tab uses its own font"); + assert_ne!(tab.style, prefix.style, "tab uses its own character formatting"); + assert_eq!(cs.styles[tab.style as usize].size, 18.0); + assert_eq!(cs.styles[tab.style as usize].fill_tint, 0.4); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert!(leaders.len() > 10); + assert_eq!(leaders.len() % 2, 0, "only complete patterns are drawn"); + for (i, g) in leaders.iter().enumerate() { + assert_eq!(g.gid, tab.face.get().glyph_for(if i % 2 == 0 { '.' } else { '-' })); + assert_eq!(g.face.id(), tab.face.id()); + assert_eq!((g.sx, g.sy, g.y, g.style, g.byte), (tab.sx, tab.sy, -4.0, tab.style, tab.byte)); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01); + } + rendered.push(leaders.iter().map(|g| (g.gid, g.x, g.y, g.sx, g.sy, g.style)).collect::>()); + } + assert_eq!(rendered[0], rendered[1], "right-indent and equivalent regular right tabs use the same leader drawing"); +} + +#[test] +fn right_indent_tab_leaders_do_not_draw_when_the_gap_cannot_fit_a_pattern() { + let text = "A\u{e006}B"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let natural: f64 = line.glyphs.iter().filter(|g| g.visible).map(|g| g.adv).sum(); + let first = &line.glyphs[0]; + let pattern_width = first.face.get().advance(first.face.get().glyph_for('-')) * first.sx; + assert!(pattern_width > 0.01); + for gap in [0.0, pattern_width / 2.0] { + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 1.0, align: TabAlign::Left, leader: "-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, natural + gap, 100.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + assert!((tab.adv - gap).abs() < 1e-6); + assert!(line.glyphs.iter().all(|g| g.len > 0 || !g.visible), "a partial leader pattern must not overlap adjacent text"); + } +} + #[test] fn footnotes_sit_at_the_column_bottom_and_push_text() { // A short frame: without footnotes the text fills it; with two footnotes the body text @@ -1371,6 +1495,23 @@ fn spanish_text_gets_spanish_hyphenation() { } } +#[test] +fn portuguese_text_gets_portuguese_hyphenation() { + let text = "matemática matemática matemática matemática matemática matemática"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!breaks.is_empty(), "Portuguese text is hyphenated"); + // Portuguese syllables: ma-te-má-tica. + for b in breaks { + let tail = b.rsplit(' ').next().unwrap_or(b); + assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); @@ -1818,6 +1959,23 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() { + // Run against the installed fonts: on macOS the first file mapping Malayalam is Arial + // Unicode MS, which leaves ക്ഷ as three glyphs. + let db = designcraft_fonts::FontDb::global(); + if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) { + return; + } + let text = "ക്ഷ"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.settings.glyph_fallback = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect(); + assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}"); + assert_ne!(glyphs[0].1, 0); +} + #[test] fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs new file mode 100644 index 00000000..24ba5c24 --- /dev/null +++ b/crates/doc/src/assets.rs @@ -0,0 +1,351 @@ +//! Placed files (assets): one asset per distinct file, the assets a document uses, and the +//! compaction a save applies (duplicates merged, unused assets left out). +//! +//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page +//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::Arc; + +use crate::story::Story; +use crate::{Asset, AssetId, Content, Document, Item}; + +/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper +/// is treated as using every asset, so nothing it references is merged away or pruned. +const MAX_DEPTH: usize = 64; + +impl Asset { + /// Whether `other` holds the same file: the same type, page, link and bytes. Files at + /// different paths stay apart even with identical bytes, since each can be updated or + /// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only + /// when they link to the same path. + pub fn same_file(&self, other: &Asset) -> bool { + if self.mime != other.mime || self.page != other.page { + return false; + } + if self.link != other.link { + return false; + } + match (self.data.is_empty(), other.data.is_empty()) { + (false, false) => self.same_bytes(other), + (true, true) => self.link.is_some(), + _ => false, + } + } + + fn same_bytes(&self, other: &Asset) -> bool { + Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data) + } +} + +/// The asset in `assets` holding the same file as `asset`. +pub fn find_same_asset(assets: &BTreeMap>, asset: &Asset) -> Option { + assets.values().find(|a| a.same_file(asset)).map(|a| a.id) +} + +/// Add `asset` to `assets` unless they already hold the same file; returns the id placements +/// should reference. A new asset with the bytes of an existing one shares its buffer. +pub fn insert_asset(assets: &mut BTreeMap>, mut asset: Asset) -> AssetId { + if let Some(id) = find_same_asset(assets, &asset) { + return id; + } + if !asset.data.is_empty() + && let Some(a) = assets.values().find(|a| a.same_bytes(&asset)) + { + asset.data = Arc::clone(&a.data); + } + let id = asset.id; + assets.insert(id, Arc::new(asset)); + id +} + +/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded +/// [`MAX_DEPTH`] (some references weren't visited). +fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + if let Content::Graphic(g) = &it.content { + f(g.asset); + } + if let Some(p) = it.media.as_ref().and_then(|m| m.poster) { + f(p); + } + it.children().iter().all(|c| item_refs(c, f, depth + 1)) +} + +/// Asset references in a story's anchored objects, graphic cells and notes (recursively). +fn story_refs(st: &Story, f: &mut impl FnMut(AssetId), depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + let mut complete = st.objects.iter().all(|o| item_refs(&o.item, f, depth + 1)); + for t in st.tables.values() { + for c in &t.cells { + if let Some(g) = &c.graphic { + f(g.asset); + } + complete &= story_refs(&c.text, f, depth + 1); + } + } + for n in st.notes.iter().chain(&st.endnotes) { + complete &= story_refs(&n.text, f, depth + 1); + } + complete +} + +/// Every asset reference in a story (its anchored objects, graphic cells and notes). +pub fn story_asset_refs(st: &Story) -> Vec { + let mut out = Vec::new(); + story_refs(st, &mut |a| out.push(a), 0); + out +} + +/// Point a story's references to a key of `map` at its value. +pub fn remap_story_assets(st: &mut Story, map: &HashMap) { + if !map.is_empty() { + remap_story(st, map, 0); + } +} + +fn item_uses(it: &Item, map: &HashMap) -> bool { + let mut hit = false; + item_refs(it, &mut |a| hit |= map.contains_key(&a), 0); + hit +} + +fn story_uses(st: &Story, map: &HashMap) -> bool { + let mut hit = false; + story_refs(st, &mut |a| hit |= map.contains_key(&a), 0); + hit +} + +fn remap_id(id: &mut AssetId, map: &HashMap) { + if let Some(n) = map.get(id) { + *id = *n; + } +} + +/// Rewrites the references in `it`, copying only the shared items that change. +fn remap_item(it: &mut Arc, map: &HashMap, depth: usize) { + if depth > MAX_DEPTH || !item_uses(it, map) { + return; + } + remap_item_in(Arc::make_mut(it), map, depth); +} + +fn remap_item_in(it: &mut Item, map: &HashMap, depth: usize) { + if let Content::Graphic(g) = &mut it.content { + remap_id(&mut g.asset, map); + } + if let Some(p) = it.media.as_mut().and_then(|m| m.poster.as_mut()) { + remap_id(p, map); + } + if let Some(kids) = it.children_mut() { + for k in kids { + remap_item(k, map, depth + 1); + } + } +} + +fn remap_story(st: &mut Story, map: &HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for o in &mut st.objects { + if item_uses(&o.item, map) { + remap_item_in(&mut Arc::make_mut(o).item, map, depth + 1); + } + } + for t in st.tables.values_mut() { + if !t.cells.iter().any(|c| c.graphic.as_ref().is_some_and(|g| map.contains_key(&g.asset)) || story_uses(&c.text, map)) { + continue; + } + for c in &mut Arc::make_mut(t).cells { + if let Some(g) = &mut c.graphic { + remap_id(&mut g.asset, map); + } + remap_story(&mut c.text, map, depth + 1); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if story_uses(&n.text, map) { + remap_story(&mut Arc::make_mut(n).text, map, depth + 1); + } + } +} + +impl Document { + /// Add a placed file, reusing the asset of an identical file already in the document; + /// returns the id to reference. + pub fn add_asset(&mut self, asset: Asset) -> AssetId { + insert_asset(&mut self.assets, asset) + } + + /// Copy asset `id` of `src` into this document (or reuse the identical asset it already + /// has); returns the id to reference here, `None` when `src` has no such asset. + pub fn adopt_asset(&mut self, src: &Document, id: AssetId) -> Option { + let a = src.assets.get(&id)?; + if let Some(same) = find_same_asset(&self.assets, a) { + return Some(same); + } + let fresh = AssetId(self.alloc()); + Some(self.add_asset(Asset { id: fresh, ..(**a).clone() })) + } + + /// Calls `f` with every asset reference: graphics on document and parent spreads (in + /// groups and multi-state objects too), media posters, anchored and inline objects, graphic + /// table cells and notes. False when nesting exceeded the depth limit. + fn asset_refs(&self, f: &mut impl FnMut(AssetId)) -> bool { + let mut complete = true; + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + complete &= item_refs(it, f, 0); + } + } + for st in self.stories.values() { + complete &= story_refs(st, f, 0); + } + complete + } + + /// The assets something in the document references. When the document nests deeper than + /// the traversal limit, every asset counts as used. + pub fn used_assets(&self) -> BTreeSet { + let mut used = BTreeSet::new(); + if !self.asset_refs(&mut |a| { + used.insert(a); + }) { + return self.assets.keys().copied().collect(); + } + used + } + + /// Point every reference to a key of `map` at its value. + pub fn remap_assets(&mut self, map: &HashMap) { + if map.is_empty() { + return; + } + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + if sp.items.iter().any(|it| item_uses(it, map)) { + for it in &mut Arc::make_mut(sp).items { + remap_item(it, map, 0); + } + } + } + for st in self.stories.values_mut() { + if story_uses(st, map) { + remap_story(Arc::make_mut(st), map, 0); + } + } + } + + /// Merge assets holding the same file into the first of them (lowest id), rewriting the + /// references; returns how many assets were removed. + pub fn merge_duplicate_assets(&mut self) -> usize { + if !self.asset_refs(&mut |_| {}) { + return 0; + } + let mut reps: HashMap<(usize, &str, u32), Vec<&Arc>> = HashMap::new(); + let mut map = HashMap::new(); + for a in self.assets.values() { + let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + match bucket.iter().find(|r| r.same_file(a)) { + Some(r) => { + map.insert(a.id, r.id); + } + None => bucket.push(a), + } + } + self.remap_assets(&map); + self.assets.retain(|id, _| !map.contains_key(id)); + map.len() + } + + /// Merge duplicate assets and drop the ones nothing references (what a saved file holds). + pub fn compact_assets(&mut self) { + self.merge_duplicate_assets(); + let used = self.used_assets(); + self.assets.retain(|id, _| used.contains(id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Graphic, ItemId, LayerId, Shape, StoryId}; + use designcraft_geom::{Affine, Rect, shapes}; + + fn asset(id: u64, data: &[u8]) -> Asset { + Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } + } + + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { + let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + it.content = Content::Graphic(Graphic { + asset: AssetId(asset), + size: (10.0, 10.0), + xf: Affine::translate((crop, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [crop; 4], + }); + it + } + + #[test] + fn identical_files_share_one_asset() { + let mut d = Document::new(&Default::default()); + let a = d.add_asset(asset(1, b"same")); + let b = d.add_asset(asset(2, b"same")); + let c = d.add_asset(asset(3, b"diff")); + let p = d.add_asset(Asset { page: 1, ..asset(4, b"same") }); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, p); + assert_eq!(d.assets.len(), 3); + // Unreadable links match by path only. + let m1 = d.add_asset(Asset { link: Some("/m/1.png".into()), ..asset(5, b"") }); + let m2 = d.add_asset(Asset { link: Some("/m/2.png".into()), ..asset(6, b"") }); + let m3 = d.add_asset(asset(7, b"")); + let m4 = d.add_asset(asset(8, b"")); + assert_ne!(m1, m2); + assert_ne!(m3, m4); + } + + #[test] + fn files_at_different_paths_stay_separate_assets() { + let mut d = Document::new(&Default::default()); + let linked = |id, path: &str| Asset { link: Some(path.into()), ..asset(id, b"same") }; + let embedded = d.add_asset(asset(1, b"same")); + let a = d.add_asset(linked(2, "/p/a.png")); + let b = d.add_asset(linked(3, "/q/b.png")); + assert_eq!(d.add_asset(linked(4, "/p/a.png")), a, "the same path placed again"); + assert_eq!(d.assets.len(), 3); + assert!(embedded != a && a != b && embedded != b); + assert_eq!(d.assets[&embedded].link, None); + assert_eq!(d.assets[&b].link.as_deref(), Some("/q/b.png")); + assert!(Arc::ptr_eq(&d.assets[&a].data, &d.assets[&b].data), "identical bytes share one buffer"); + } + + #[test] + fn compaction_merges_duplicates_and_drops_unused_assets() { + let mut d = Document::new(&Default::default()); + for (id, data) in [(1, b"one"), (2, b"one"), (3, b"two"), (4, b"old")] { + d.assets.insert(AssetId(id), Arc::new(asset(id, data))); + } + let sp = Arc::make_mut(d.spreads.first_mut().unwrap()); + sp.items.push(Arc::new(graphic_item(10, 1, 1.0))); + sp.items.push(Arc::new(graphic_item(11, 2, 2.0))); + // A graphic cell and an anchored object in a story. + let mut st = Story::new(StoryId(20)); + st.objects.push(Arc::new(crate::AnchoredObject::new(graphic_item(12, 3, 3.0), Default::default()))); + d.stories.insert(StoryId(20), Arc::new(st)); + assert_eq!(d.used_assets(), [1, 2, 3].map(AssetId).into_iter().collect()); + + d.compact_assets(); + assert_eq!(d.assets.keys().copied().collect::>(), vec![AssetId(1), AssetId(3)]); + let crops: Vec<(AssetId, f64)> = d.spreads[0].items.iter().filter_map(|i| i.graphic().map(|g| (g.asset, g.crop[0]))).collect(); + assert_eq!(crops, vec![(AssetId(1), 1.0), (AssetId(1), 2.0)]); + } +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 3bb82713..38de38ac 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { return true; } - designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner) + // Type on a path: the letters stand over the path, so a click on them hits it too. + let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 }; + designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner) } + +/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, +/// either side (the type may sit on or under the path). +const PATH_TYPE_REACH: f64 = 12.0; diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe..0a701998 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -345,8 +345,12 @@ pub struct TextFrameOptions { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct PathType { - /// Where the text starts, as a distance along the path. + /// Where the text starts, as a distance along the path (the start bracket). pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, /// Run the other way (the text sits on the other side). pub flip: bool, pub align: PathAlign, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4b69e86..a643cc08 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod anchored; pub mod arabic; pub mod arrow; +pub mod assets; pub mod attrs; pub mod build; pub mod cjk; @@ -36,6 +37,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; +pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; pub use attrs::*; pub use datamerge::{ DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, @@ -450,6 +452,16 @@ pub struct Asset { pub page: u32, } +impl Asset { + /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. + pub fn is_raster(&self) -> bool { + matches!( + self.mime.as_str(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop" + ) + } +} + fn is_zero(v: &u32) -> bool { *v == 0 } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e9..9449be93 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -695,6 +695,79 @@ impl Table { self.set_regions(®ions); } + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a /// new row (column) that the cells beside it span, the row height (column width) shared. @@ -719,10 +792,11 @@ impl Table { return; } if horizontal { - let h = self.rows[or].height; + let row = self.rows[or].clone(); self.insert_rows(or + 1, 1); - self.rows[or].height = h / 2.0; - self.rows[or + 1].height = h / 2.0; + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; // insert_rows doesn't grow regions that end at the split row; everything else on it // spans the new row now. let mut regions: Vec<_> = self.regions(); @@ -1255,4 +1329,89 @@ mod split_tests { assert_eq!(m.nrows(), 4); assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9f05fc96..2bbe841c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma workspace = true [dependencies] +craft-print = { workspace = true } designcraft-images = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs new file mode 100644 index 00000000..7eed2f02 --- /dev/null +++ b/crates/engine/src/atomic.rs @@ -0,0 +1,359 @@ +//! Crash-safe file replacement: how documents, exports and other user files reach the disk. +//! +//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary +//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then +//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename +//! fails, the temporary file is removed and the destination keeps its old bytes. +//! +//! - A replaced file keeps its permissions (Unix mode). +//! - A symbolic link keeps pointing at its file: the file it names is replaced. +//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the +//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff. +//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no +//! fallback to writing in place. +//! - Flushing the parent folder after replacement is best effort. A successful save confirms +//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if +//! the file system rejects that folder flush. +//! +//! The web has no file system, so this module is not built for `wasm32`. + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +/// How many times a replacing rename is tried before its error is returned. +const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 }; +/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`]. +const RENAME_FIRST_DELAY_MS: u64 = 10; +const RENAME_MAX_DELAY_MS: u64 = 200; +/// Symbolic links followed before giving up (a loop, or an absurd chain). +const MAX_LINKS: usize = 40; + +/// Atomically replace (or create) `path` with `bytes`. See the module docs. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + write_atomic_with(path, |f| f.write_all(bytes)) +} + +/// [`write_atomic`] for a command's `path` parameter: the error names the path. +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { + write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) +} + +/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When +/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and +/// the error is returned. +pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let target = resolve_links(path)?; + target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; + let dir = match target.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + }; + let existing = std::fs::metadata(&target).ok(); + if existing.is_some() { + check_writable(&target)?; + } + let mut tmp = create_temp(&dir, existing.as_ref())?; + if let Some(existing) = existing { + // Restore the exact mode before any document bytes are written, and flush it along with + // the content. The caller's umask may have restricted the initial temporary-file mode. + keep_permissions(tmp.as_file(), &existing)?; + } + fill(tmp.as_file_mut())?; + tmp.as_file().sync_all()?; + // Closing the file before renaming also allows replacement on Windows. TempPath removes the + // staging file on every error; NamedTempFile does the same if the writer unwinds. + let tmp = tmp.into_temp_path(); + retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?; + // The new bytes are in place. A failed folder flush can't be undone and must not report the + // save as failed. + let _ = sync_dir(&dir); + Ok(()) +} + +/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one +/// volume). A short randomized name supports long destination names and skips existing entries. +fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-save-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Private replacements are private from creation. New files retain fs::write's normal + // 0666 mode restricted by the caller's umask. + let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode()); + builder.permissions(std::fs::Permissions::from_mode(mode)); + } + #[cfg(not(unix))] + let _ = existing; + builder.tempfile_in(dir) +} + +/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling +/// link resolves to the missing file it names, which is then created. +fn resolve_links(path: &Path) -> io::Result { + let mut p = path.to_path_buf(); + for _ in 0..MAX_LINKS { + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_symlink() => { + let to = std::fs::read_link(&p)?; + p = match p.parent() { + Some(dir) if to.is_relative() => dir.join(to), + _ => to, + }; + } + _ => return Ok(p), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) +} + +/// A rename replaces a file the user can't write (Unix only needs the folder to be writable); +/// refuse, as writing it in place would. Opening for writing without truncating changes nothing. +#[cfg(unix)] +fn check_writable(target: &Path) -> io::Result<()> { + OpenOptions::new().write(true).open(target).map(drop) +} + +/// Windows refuses to rename over a read-only file by itself. +#[cfg(not(unix))] +fn check_writable(_: &Path) -> io::Result<()> { + Ok(()) +} + +/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag, +/// and a read-only file can't be replaced). +#[cfg(unix)] +fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> { + tmp.set_permissions(replaced.permissions()) +} + +#[cfg(not(unix))] +fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> { + Ok(()) +} + +/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with +/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once. +fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { + let mut delay = RENAME_FIRST_DELAY_MS; + let mut left = tries.max(1); + loop { + left = left.saturating_sub(1); + match rename() { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => { + std::thread::sleep(std::time::Duration::from_millis(delay)); + delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS); + } + r => return r, + } + } +} + +/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows; +/// this does not establish power-loss durability for its directory entries. +#[cfg(unix)] +fn sync_dir(dir: &Path) -> io::Result<()> { + File::open(dir)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_dir(_: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// The names in `d`, sorted. + fn names(d: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v + } + + #[test] + fn replaces_and_creates_without_leftovers() { + let d = dir("replace"); + let path = d.join("doc.designcraft"); + write_atomic(&path, b"first").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + write_atomic(&path, b"second, longer").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second, longer"); + write_atomic(&path, b"3").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"3"); + assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_original_and_no_temporary_file() { + let d = dir("fail"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + let r = write_atomic_with(&path, |f| { + f.write_all(b"half a docu")?; + Err(io::Error::other("disk full")) + }); + assert_eq!(r.unwrap_err().to_string(), "disk full"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed"); + // A folder that doesn't exist: an error, nothing written. + assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft"]); + // A folder in the way: an error, the folder stays and nothing is left beside it. + std::fs::create_dir(d.join("folder")).unwrap(); + assert!(write_atomic(&d.join("folder"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft", "folder"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_long_destination_name_does_not_lengthen_the_temporary_name() { + let d = dir("long-name"); + let name = format!("{}.designcraft", "a".repeat(235)); + let path = d.join(&name); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), [name]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn private_permissions_are_preserved_while_the_file_is_written() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("private-staging"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic_with(&path, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + file.write_all(b"private document")?; + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"private document"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn an_unwinding_writer_removes_its_partial_temporary_file() { + let d = dir("unwinding-writer"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + let result = std::panic::catch_unwind(|| { + write_atomic_with(&path, |file| { + file.write_all(b"partial private document")?; + panic!("synthetic writer panic"); + }) + }); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_rename_is_retried_only_while_access_is_denied() { + let mut calls = 0; + let r = retry_rename(3, || { + calls += 1; + if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) } + }); + assert!(r.is_ok() && calls == 3); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied)); + assert_eq!(calls, 3, "gives up after the last try"); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::CrossesDevices)) + }); + assert!(r.is_err() && calls == 1, "other errors are not retried"); + } + + #[cfg(unix)] + #[test] + fn permissions_are_kept_and_unwritable_files_are_not_replaced() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("perms"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + // A read-only file stays as it is (root may write it anyway). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); + if OpenOptions::new().write(true).open(&path).is_err() { + assert!(write_atomic(&path, b"newer").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), ["private.designcraft"]); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let _ = std::fs::remove_dir_all(&d); + } + + /// A folder the user can't write: the save fails and the original stays. + #[cfg(unix)] + #[test] + fn an_unwritable_folder_fails_without_touching_the_file() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("ro-dir"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes anyway; the check only means something for an ordinary user. + let probe = d.join("probe"); + let unwritable = std::fs::write(&probe, b"").is_err(); + if unwritable { + assert!(write_atomic(&path, b"new").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + } + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_file(&probe); + assert_eq!(names(&d), ["doc.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn a_symbolic_link_keeps_pointing_at_the_replaced_file() { + let d = dir("link"); + std::fs::create_dir(d.join("real")).unwrap(); + let target = d.join("real").join("doc.designcraft"); + std::fs::write(&target, b"old").unwrap(); + let link = d.join("link.designcraft"); + std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(names(&d), ["link.designcraft", "real"]); + assert_eq!(names(&d.join("real")), ["doc.designcraft"]); + // A loop of links is an error, not a hang. + std::os::unix::fs::symlink("b", d.join("a")).unwrap(); + std::os::unix::fs::symlink("a", d.join("b")).unwrap(); + assert!(write_atomic(&d.join("a"), b"x").is_err()); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 9631dac2..fbf80ed9 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path))) + crate::atomic::write_file(&b.path, &bytes) } #[cfg(target_arch = "wasm32")] @@ -47,20 +47,176 @@ fn load(path: &str) -> Result { if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } - super::file::from_bytes(&bytes) + super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } -fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}"))); - #[cfg(target_arch = "wasm32")] - { - let _ = (path, d); - Err(EngineError::Other("books need a file system".into())) +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_CHAPTERS: usize = 4096; + +/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private +/// temporary directory; only one document and serialized blob are live while staging. +#[cfg(not(target_arch = "wasm32"))] +struct ChapterBatch { + directory: tempfile::TempDir, + chapters: Vec<(String, tempfile::TempPath)>, + command: &'static str, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ChapterBatch { + fn new(chapters: usize, command: &'static str) -> Result { + if chapters > MAX_BATCH_CHAPTERS { + return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters"))); + } + let directory = tempfile::Builder::new() + .prefix(".designcraft-book-") + .tempdir() + .map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?; + Ok(Self { directory, chapters: Vec::with_capacity(chapters), command }) + } + + fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> { + use std::io::Write; + + // Propagate serialization errors; an empty fallback must never replace a chapter. + let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut file = tempfile::Builder::new() + .prefix("chapter-") + .tempfile_in(self.directory.path()) + .map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + self.chapters.push((path.to_string(), file.into_temp_path())); + Ok(()) + } + + fn commit(self) -> Result<()> { + self.commit_with(write_staged_chapter) + } + + fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { + let mut completed = Vec::with_capacity(self.chapters.len()); + for (path, staged) in self.chapters { + if let Err(e) = write(std::path::Path::new(&path), &staged) { + // Each file is atomic; the whole book is not. Keep the successful prefix visible + // rather than pretending that an error undid earlier chapter replacements. + return Err(EngineError::Other(format!( + "{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}", + self.command + ))); + } + completed.push(path); + } + Ok(()) } } +#[cfg(not(target_arch = "wasm32"))] +fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Batch commands only write closed native chapters. IDML is an imported source, and an open +/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. +#[cfg(not(target_arch = "wasm32"))] +fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { + use std::path::Path; + + if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); + } + let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); + } + if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + return Err(bad(command, format!("chapter {path} is not a regular file"))); + } + // Check both the file and its resolved parent without truncating it. Replacement needs a + // writable directory even when an in-place write would be permitted. + std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; + tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn paginate(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + if b.documents.is_empty() { + return Ok(json!({"pages": 0})); + } + let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?; + let mut next = 1u32; + for path in &b.documents { + writable_chapter(s, path, "book.paginate")?; + let mut d = load(path)?; + start_at(&mut d, next); + let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?; + next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?; + batch.stage(path, &d)?; + } + batch.commit()?; + Ok(json!({"pages": next - 1})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sync_styles(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?; + let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; + let src = load(src_path)?; + let styles = src.styles.clone(); + let swatches = src.swatches.clone(); + drop(src); + let mut n = 0; + for (i, path) in b.documents.iter().enumerate() { + if i == b.style_source { + continue; + } + writable_chapter(s, path, "book.syncStyles")?; + let mut d = load(path)?; + { + let st = d.styles_mut(); + for ps in &styles.paragraph { + st.paragraph.retain(|x| x.name != ps.name); + st.paragraph.push(ps.clone()); + } + for cs in &styles.character { + st.character.retain(|x| x.name != cs.name); + st.character.push(cs.clone()); + } + } + for sw in &swatches { + match d.swatches.iter_mut().find(|w| w.name == sw.name) { + Some(w) if !w.locked => *w = sw.clone(), + Some(_) => {} + None => d.swatches.push(sw.clone()), + } + } + batch.stage(path, &d)?; + n += 1; + } + batch.commit()?; + Ok(json!({"synced": n})) +} + +#[cfg(target_arch = "wasm32")] +fn paginate(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(target_arch = "wasm32")] +fn sync_styles(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "book/safety_tests.rs"] +mod safety_tests; + /// Make page numbering of `d` start at `n` (the first section). +#[cfg(not(target_arch = "wasm32"))] fn start_at(d: &mut designcraft_doc::Document, n: u32) { if !d.sections.iter().any(|x| x.start == 0) { d.sections.insert( @@ -108,15 +264,16 @@ pub fn specs() -> Vec { cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string(); load(&path)?; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len())); b.documents.insert(at, path); - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.remove", format!("no document {i}"))); } @@ -124,17 +281,19 @@ pub fn specs() -> Vec { if b.style_source >= b.documents.len() { b.style_source = 0; } - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.styleSource", format!("no document {i}"))); } b.style_source = i; - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| { @@ -149,48 +308,10 @@ pub fn specs() -> Vec { Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs})) }), cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let mut next = 1u32; - for p in &b.documents { - let mut d = load(p)?; - start_at(&mut d, next); - next += d.page_count() as u32; - store(p, &d)?; - } - Ok(json!({"pages": next - 1})) + paginate(s) }), cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; - let src = load(src_path)?; - let mut n = 0; - for (i, p) in b.documents.iter().enumerate() { - if i == b.style_source { - continue; - } - let mut d = load(p)?; - { - let st = d.styles_mut(); - for ps in &src.styles.paragraph { - st.paragraph.retain(|x| x.name != ps.name); - st.paragraph.push(ps.clone()); - } - for cs in &src.styles.character { - st.character.retain(|x| x.name != cs.name); - st.character.push(cs.clone()); - } - } - for sw in &src.swatches { - match d.swatches.iter_mut().find(|w| w.name == sw.name) { - Some(w) if !w.locked => *w = sw.clone(), - Some(_) => {} - None => d.swatches.push(sw.clone()), - } - } - store(p, &d)?; - n += 1; - } - Ok(json!({"synced": n})) + sync_styles(s) }), cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| { let b = s.book.clone().ok_or_else(no_book)?; @@ -210,7 +331,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), @@ -235,6 +356,7 @@ mod tests { s.execute("file.new", &json!({"pages": pages})).unwrap(); s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap(); s.execute("file.saveAs", &json!({"path": p(name)})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); } s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap(); s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap(); diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs new file mode 100644 index 00000000..e4ec20ef --- /dev/null +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -0,0 +1,52 @@ +use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use crate::Session; +use serde_json::json; + +#[test] +fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.designcraft"); + let second = dir.path().join("second.designcraft"); + let third = dir.path().join("third.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = (*s.doc().unwrap().doc).clone(); + let mut changed = old.clone(); + changed.sections.first_mut().unwrap().start_number = Some(9); + let old_bytes = designcraft_format::save(&old).unwrap(); + for path in [&first, &second, &third] { + std::fs::write(path, &old_bytes).unwrap(); + } + let mut batch = ChapterBatch::new(3, "book.paginate").unwrap(); + for path in [&first, &second, &third] { + batch.stage(path.to_str().unwrap(), &changed).unwrap(); + } + let staging = batch.directory.path().to_path_buf(); + let mut calls = 0; + let error = batch + .commit_with(|path, staged| { + calls += 1; + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + }) + .unwrap_err() + .to_string(); + assert_eq!(calls, 2); + assert!(error.contains("synthetic disk full"), "{error}"); + assert!(error.contains(second.to_str().unwrap()), "{error}"); + let completed = error.split("completed chapter paths:").nth(1).unwrap(); + assert!(completed.contains(first.to_str().unwrap()), "{error}"); + assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}"); + assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9)); + assert_eq!(std::fs::read(second).unwrap(), old_bytes); + assert_eq!(std::fs::read(third).unwrap(), old_bytes); + assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure"); +} + +#[test] +fn chapter_record_limit_is_checked_before_staging_allocation() { + let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") { + Err(e) => e, + Ok(_) => panic!("oversized book batch accepted"), + }; + assert!(error.to_string().contains("4096")); +} diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index deb0948b..89dd0c74 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document let new_id = ItemId(dst.alloc()); maps.items.insert(old, new_id); it.id = new_id; + if let Some(m) = it.media.as_mut() + && let Some(p) = m.poster + { + m.poster = dst.adopt_asset(src, p).or(Some(p)); + } match &mut it.content { Content::Text(tf) => { let new_sid = if let Some(n) = maps.stories.get(&tf.story) { @@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document let n = StoryId(dst.alloc()); let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); st.id = n; + let mut assets = HashMap::new(); + for a in designcraft_doc::story_asset_refs(&st) { + if let Some(na) = dst.adopt_asset(src, a) + && na != a + { + assets.insert(a, na); + } + } + designcraft_doc::remap_story_assets(&mut st, &assets); st.frames.clear(); dst.stories.insert(n, Arc::new(st)); maps.stories.insert(tf.story, n); @@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document } } Content::Graphic(g) => { - if let Some(a) = src.assets.get(&g.asset) { - dst.assets.entry(g.asset).or_insert_with(|| a.clone()); + if let Some(a) = dst.adopt_asset(src, g.asset) { + g.asset = a; } } Content::Group { items } => { @@ -355,21 +369,18 @@ fn place_image( }; let (nw, nh) = (f64::from(pw), f64::from(ph)); let mode = fitting_of(&options.fitting); - let aid = AssetId(doc.alloc()); + let fresh = AssetId(doc.alloc()); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string()); let link = options.link_images.then(|| path.to_string_lossy().into_owned()); - doc.assets.insert( - aid, - Arc::new(Asset { - id: aid, - name, - mime: designcraft_render::image_mime(&bytes).to_string(), - link, - data: Arc::new(bytes), - pixels: Some((pw, ph)), - page: 0, - }), - ); + let aid = doc.add_asset(Asset { + id: fresh, + name, + mime: designcraft_render::image_mime(&bytes).to_string(), + link, + data: Arc::new(bytes), + pixels: Some((pw, ph)), + page: 0, + }); let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; if let Some(xf) = g.fitted(inner, mode) { g.xf = xf; diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493df..6ab10f3d 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter { if ext == "csv" { return Delimiter::Comma; } - let header = first_physical_line(text); - if header.contains('\t') { + let tab = header_delimiter_candidate(text, '\t'); + let semicolon = header_delimiter_candidate(text, ';'); + let comma = header_delimiter_candidate(text, ','); + // Prefer formats with coherent quoted-field boundaries. If none separates fields, + // retain the legacy permissive choice; this is detection, not record validation. + let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent); + let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent); + if eligible(&tab) { Delimiter::Tab - } else if header.contains(';') && !header.contains(',') { + } else if eligible(&semicolon) && !eligible(&comma) { Delimiter::Semicolon } else { Delimiter::Comma @@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String { Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } -fn first_physical_line(text: &str) -> &str { - text.split('\n').next().unwrap_or("").trim_end_matches('\r') +struct HeaderDelimiterCandidate { + found: bool, + coherent: bool, +} + +/// A candidate separator outside quotes in its first nonempty logical record. +/// Only that candidate starts a new field. Quotes in an unquoted field stay literal; +/// Non-whitespace text after a quoted field closes lowers coherence, not validity. +fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate { + let mut found = false; + let mut coherent = true; + let mut quoted = false; + let mut after_quote = false; + let mut field_start = true; + let mut nonempty = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + nonempty = true; + } else { + quoted = false; + after_quote = true; + } + } else if !c.is_whitespace() { + nonempty = true; + } + continue; + } + match c { + '"' if field_start => { + quoted = true; + field_start = false; + } + separator if separator == delimiter => { + found = true; + field_start = true; + after_quote = false; + } + '\r' | '\n' => { + if nonempty { + break; + } + found = false; + coherent = true; + field_start = true; + after_quote = false; + } + _ => { + // Whitespace may follow a closing quote, but does not start a new field + // or allow a later non-whitespace suffix to regain coherence. + coherent &= !after_quote || c.is_whitespace(); + nonempty |= !c.is_whitespace(); + field_start = false; + } + } + } + HeaderDelimiterCandidate { found, coherent: coherent && !quoted } } /// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. @@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { let mut row: Vec = Vec::new(); let mut field = String::new(); let mut quoted = false; + let mut field_start = true; let mut line = 1usize; let mut row_line = 1usize; let mut data_rows = 0usize; @@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { }; while let Some(c) = chars.next() { - if c == '\n' { + if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) { line = line.saturating_add(1); } if quoted { match c { '"' if chars.peek() == Some(&'"') => { - field.push('"'); + if !discarding { + field.push('"'); + } chars.next(); } '"' => quoted = false, - _ => field.push(c), + _ if !discarding => field.push(c), + _ => {} } continue; } if c == delim { + field_start = true; if discarding { field.clear(); continue; @@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { continue; } match c { - '"' if field.is_empty() && !discarding => quoted = true, - '\r' => {} - '\n' => { + '"' if field_start => { + quoted = true; + field_start = false; + } + '\r' | '\n' => { + if c == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + line = line.saturating_add(1); + } finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; row_line = line; + field_start = true; + } + _ => { + field_start = false; + if !discarding { + field.push(c); + } } - _ if discarding => {} - _ => field.push(c), } } if !field.is_empty() || !row.is_empty() || discarding { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index e4636d10..f4c2004e 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -85,6 +85,282 @@ fn csv_parsing() { assert_eq!(quoted[1][0], "Ada\nGrace"); } +#[test] +fn data_source_sniffs_quoted_logical_headers() { + for (body, delimiter, names) in [ + ("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]), + ("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]), + ("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]), + ("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]), + ("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]), + ] { + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(result["records"], 1, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, delimiter, "{body:?}"); + assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::>(), names, "{body:?}"); + assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}"); + } +} + +#[test] +fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() { + for ending in ["\n", "\r\n", "\r"] { + for (body, delimiter, names) in [ + (format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]), + (format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + (format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]), + (format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + ] { + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_prefers_coherent_quoted_field_boundaries() { + for ending in ["\n", "\r\n", "\r"] { + for (header, first, second, delimiter, names, rows, warnings) in [ + ( + "\"Name\";\"Last, First\"", + "\"Ada\";\"Lovelace, Countess\"", + "\"Grace\";\"Hopper, Admiral\"", + Delimiter::Semicolon, + vec!["Name", "Last, First"], + vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]], + vec![], + ), + ( + "\"Name\",\"Region\tCity\"", + "\"Ada\",\"London\"", + "\"Grace\",\"Paris\"", + Delimiter::Comma, + vec!["Name", "Region\tCity"], + vec![vec!["Ada", "London"], vec!["Grace", "Paris"]], + vec![], + ), + ( + "\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"", + "\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"", + "\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"", + Delimiter::Semicolon, + vec!["Name", "City", "Last, \"First\"", "Title"], + vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ( + "\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"", + "\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"", + "\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"", + Delimiter::Comma, + vec!["Name", "Role", "Region\t\"City\"", "Title"], + vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ] { + let body = format!("{header}{ending}{first}{ending}{second}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, rows, "{body:?}"); + assert_eq!(automatic.warnings, warnings, "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_retains_priority_and_permissive_fallback() { + for ending in ["\n", "\r\n", "\r"] { + for (header, row, delimiter, names, cells) in [ + // Equally coherent candidates retain Tab, then Comma, then Semicolon priority. + ("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]), + ("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]), + // No coherent candidate separates fields here. Keep parsing the trailing text + // after a quoted field exactly as before, using the same delimiter priority. + ( + "\"Name\"suffix,Title\tCity", + "\"Ada\"suffix,Analyst\tLondon\tignored", + Delimiter::Tab, + ["Namesuffix,Title", "City"], + ["Adasuffix,Analyst", "London"], + ), + ( + "\"Name\"suffix;Title,City", + "\"Ada\"suffix;Analyst,London,ignored", + Delimiter::Comma, + ["Namesuffix;Title", "City"], + ["Adasuffix;Analyst", "London"], + ), + ("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]), + ] { + let body = format!("{header}{ending}{row}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_allows_post_quote_whitespace() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for (header, separator, delimiter, names) in [ + (format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]), + (format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]), + (format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]), + ] { + let body = format!( + "{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}" + ); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + // Header classification trims; record values must keep their whitespace. + assert_eq!( + automatic.rows, + vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]], + "{body:?}" + ); + assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for suffix in ["suffix", "\"suffix\""] { + let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}"); + // Both separated formats have incompatible post-quote suffixes. Keep + // the permissive Comma fallback, including literal quotes in the name. + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap(); + assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}"); + assert_eq!( + automatic.fields, + vec![ + DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text }, + DataField { name: "First\"".into(), kind: DataFieldKind::Text }, + ], + "{body:?}" + ); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_literal_quote_header_merges_the_detected_column() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n"; + let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(selected["records"], 2); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, Delimiter::Tab); + assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]); + exec(&mut s, "data.placeholder.add", json!({"field": "City\""})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["London", "Paris"]); +} + +#[test] +fn data_source_preserves_quoted_newlines_and_counts_record_lines() { + for ending in ["\n", "\r\n", "\r"] { + let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = select_csv(&mut s, &body); + assert_eq!(result["records"], 2, "{ending:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]); + assert_eq!(source.warnings.len(), 1); + assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings); + } +} + +#[test] +fn data_source_quoted_header_and_cr_records_merge_independently() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r"; + exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"})); + let template = s.documents()[0].doc.clone(); + let result = exec(&mut s, "data.merge", json!({})); + assert_eq!(result["records"], 2); + assert_eq!(result["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap()); +} + +#[test] +fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() { + for ending in ["\n", "\r\n", "\r"] { + for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}"); + let selected = select_csv(&mut s, &body); + assert_eq!(selected["records"], 2, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}"); + assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings); + assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings); + assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + } + } +} + +#[test] +fn data_source_delimiter_override_and_known_extensions_win() { + let body = "Name;City\nAda;London\n"; + let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap(); + assert_eq!(csv.delimiter, Delimiter::Comma); + assert_eq!(csv.rows, vec![vec!["Ada;London"]]); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap(); + assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]); + let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap(); + assert_eq!(tab.delimiter, Delimiter::Tab); + assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]); +} + #[test] fn text_sniff_padding_skips_and_warnings() { let dir = scratch("sniff"); diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 6cee420e..6bb73176 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { if ts.cell.is_some() { return Err(bad("note.new", "notes go in story text")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { @@ -36,7 +36,7 @@ pub fn specs() -> Vec { }), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; - let (text, author) = (str_param(p, "text").map(str::to_string), str_param(p, "author").map(str::to_string)); + let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); s.edit(|d, _| { let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?; let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?; diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index bdf9ca69..664b5c10 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -4,7 +4,7 @@ use designcraft_doc::{ParaFormat, SpreadRef, StoryId}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -20,7 +20,7 @@ pub fn specs() -> Vec { ), cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| { let (sid, id) = target(p)?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let style = d.endnote_options.para_style.clone(); let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?; @@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { if st.doc.endnote_story == Some(ts.story) { return Err(bad(ID, "the endnote frame lists the endnotes")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { // The endnote frame: a text frame in the margins of a new last page. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 15604fc0..9905c2dc 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -47,7 +47,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &r.bytes)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -62,7 +62,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -131,7 +131,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -200,7 +200,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -643,7 +643,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::atomic::write_file(path, bytes) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f871a43e..a1919b89 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,16 +18,18 @@ pub fn specs() -> Vec { cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}", + "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); - let bytes = to_bytes(&s.doc()?.doc); + let mut copy = (*s.doc()?.doc).clone(); + super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), @@ -37,7 +39,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { let dir = recovery_dir(s, p)?; @@ -237,24 +239,26 @@ fn file_save(s: &mut Session, p: &Value) -> Result { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - { - let mut d = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path)); - if d.data_merge != st.doc.data_merge { - st.doc = Arc::new(d); - st.revision = st.revision.saturating_add(1); - } - } - let bytes = to_bytes(&st.doc); + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; + if paths_changed { + st.doc = saved; + st.revision = st.revision.saturating_add(1); + } st.path = Some(path.clone()); st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = &s.recovery_dir { - let uid = s.doc()?.uid; - crate::recovery::discard(dir, uid); + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); } Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -313,8 +317,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result { .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. - let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64)); + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); // Image Import Options › Crop to: the frame shows that box of the PDF page. let crop_box = match str_param(p, "pdfCrop") { Some(k) if designcraft_render::is_pdf(&bytes) => { @@ -330,15 +335,15 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); let want_w = p.get("width").and_then(Value::as_f64); s.edit(|d, sel| { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); + let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }); // The width the whole page (or image) gets; the height follows the proportions. let w = match want_w { Some(w) => w, @@ -350,14 +355,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let id = if let Some(fid) = target_frame { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); - // Fill frame proportionally. + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. let k = (r.width() / nw).max(r.height() / nh); let (gw, gh) = (nw * k, nh * k); it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k), - auto_fit: designcraft_doc::Fitting::FillProportionally, + auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], }); @@ -440,23 +445,20 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option) fn place_load(s: &mut Session, p: &Value) -> Result { let (bytes, name, link) = read_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); let aid = s.edit(|d, _| { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert( - aid, - Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }), - ); - Ok(aid) + Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) })) })?; - s.loaded = Some((aid, (pw as f64, ph as f64))); + s.loaded = Some((aid, natural)); s.set_tool("placeGun"); Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) } fn place_drop(s: &mut Session, p: &Value) -> Result { let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "place.drop")?; let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); let rect = super::rect_param(p, "rect"); @@ -471,7 +473,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), - auto_fit: designcraft_doc::Fitting::FillProportionally, + auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], }); @@ -520,7 +522,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -548,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re if ids.is_empty() { return Err(bad(ID, format!("page {page} has no objects"))); } - let to = super::spread_param(p, "spread"); + let to = super::spread_param(p, ID)?; let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), @@ -584,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("snippet.place", "the snippet is empty")); } - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "snippet.place")?; let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), @@ -645,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); let uid = s.doc()?.uid; - let st = s.doc_mut()?; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; let mut fresh = DocState::new(d, Some(path.clone())); fresh.fonts = fonts; fresh.uid = uid; - fresh.revision = st.revision + 1; - *st = fresh; - if let Some(dir) = &s.recovery_dir { - crate::recovery::discard(dir, uid); - } + fresh.revision = s.doc()?.revision + 1; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; Ok(json!({"path": path})) } #[cfg(target_arch = "wasm32")] @@ -689,6 +690,24 @@ mod pdf_crop_tests { assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } } #[cfg(test)] @@ -719,6 +738,37 @@ mod layout_place_tests { } } +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod document_fonts_tests { use std::path::{Path, PathBuf}; diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 87816040..5dc0bc8a 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result { over.set_json(k, v).map_err(|e| bad("find.change", e))?; } } + super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let targets = scope(s, p)?; s.edit(|d, sel| { let mut count = 0usize; diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 47aacdc8..1111cb3b 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = guide_spread(p, "guide.deleteAll")?; + let r = super::spread_param(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -41,7 +41,7 @@ pub fn specs() -> Vec { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -71,23 +71,6 @@ fn optional_usize(p: &Value, key: &str, command: &str) -> Result> p.get(key).map(|value| checked_usize(value, key, command)).transpose() } -/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. -fn guide_spread(p: &Value, command: &str) -> Result { - let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; - if value.is_number() { - return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); - } - let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; - let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; - let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; - let index = checked_usize(index, "spread.index", command)?; - match kind { - "doc" => Ok(SpreadRef::Doc(index)), - "parent" => Ok(SpreadRef::Parent(index)), - _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), - } -} - fn finite_number(p: &Value, key: &str, command: &str) -> Result> { let Some(value) = p.get(key) else { return Ok(None) }; let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; @@ -113,7 +96,7 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result { let o = orientation(p)?; let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; - let r = guide_spread(p, "guide.add")?; + let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { @@ -139,7 +122,7 @@ fn add(s: &mut Session, p: &Value) -> Result { fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; - Ok((guide_spread(p, cmd)?, pi, i)) + Ok((super::spread_param(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -237,7 +220,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = guide_spread(p, "layout.createGuides")?; + let r = super::spread_param(p, "layout.createGuides")?; let only = optional_usize(p, "page", "layout.createGuides")?; let page_count = { let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 8a65ec91..96498e86 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path", has_doc, export_idml), cmd!(noundo "file.openIdml", "Open IDML", [], None, - "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}", + "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}", always, open_idml), ] } @@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -34,6 +34,11 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { + import_report(bytes, dir).map(|(document, _)| document) +} + +/// [`import`], with the problems that did not stop it (parts missing from the package, …). +pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec)> { #[cfg(not(target_arch = "wasm32"))] let resolved = std::cell::RefCell::new(std::collections::HashMap::new()); let read = |link: &str| -> Option> { @@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { None } }; - let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; + let designcraft_idml::Imported { document, warnings } = + designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; #[cfg(not(target_arch = "wasm32"))] let document = { let mut document = document; @@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { resolve_packaged_links(&mut document, dir); document }; - Ok(document) + Ok((document, warnings)) } #[cfg(not(target_arch = "wasm32"))] @@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { } else { return Err(bad("file.openIdml", "missing `path` or `base64`")); }; - let mut d = import(&bytes, dir.as_deref())?; + let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?; if let Some(n) = name { d.title = n; } - let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { + let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { Some(path) => super::file::load_document_fonts(&mut d, path), None => (None, 0, Vec::new()), }; + warnings.extend(font_warnings); // Never save over the .idml with the native format: the document starts unsaved. let mut st = DocState::new(d, None); st.fonts = fonts; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f0e77af5..384b4f82 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?; + crate::atomic::write_file(p, &bytes)?; } Ok(()) } @@ -86,10 +86,12 @@ pub fn specs() -> Vec { let n = st.selection.items.len(); (str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string()) }; - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); + let index = lib.items.len(); lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) }); lib.save()?; - Ok(json!({"index": lib.items.len() - 1})) + s.library = Some(lib); + Ok(json!({"index": index})) }), cmd!( "library.place", @@ -107,13 +109,14 @@ pub fn specs() -> Vec { } ), cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| { - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize; if i >= lib.items.len() { return Err(bad("library.remove", format!("no item {i}"))); } lib.items.remove(i); lib.save()?; + s.library = Some(lib); ok() }), cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| { diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b..f504fe66 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { |s, p| { let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?); let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "story.placeAndLink")?; let layer = s.doc()?.active_layer; s.edit(|d, sel| { if d.story(parent).is_none() { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c05c6dad..73de3ea7 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -112,8 +112,10 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } fn list(d: &Document) -> Vec { + let used = d.used_assets(); d.assets .values() + .filter(|a| used.contains(&a.id)) .map(|a| { let uses: Vec = uses(d, a.id) .into_iter() @@ -311,3 +313,118 @@ mod relink_folder_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod shared_asset_tests { + use super::*; + + fn png(w: u32, h: u32) -> Vec { + designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() + } + + #[test] + fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = super::super::file::base64_encode(&png(10, 10)); + s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 1); + assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().doc.assets.len(), 1); + + // A new image into a frame: the old asset stays in memory (undo) but not in the file. + let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = super::super::file::base64_encode(&png(20, 10)); + let c = super::super::file::base64_encode(&png(30, 10)); + s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap(); + s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.assets.len(), 3); + assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["a.png", "c.png"]); + } + + #[test] + fn files_with_identical_bytes_at_different_paths_keep_their_own_links() { + let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.png"), dir.join("b.png")); + std::fs::write(&a, png(10, 10)).unwrap(); + std::fs::write(&b, png(10, 10)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (path, x) in [(&a, 72), (&b, 200)] { + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap(); + } + assert_eq!(s.doc().unwrap().doc.assets.len(), 2); + let rows = |s: &mut Session| -> Vec<(String, Value)> { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect() + }; + let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::>(); + let before = rows(&mut s); + assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]); + + // b.png changes on disk: updating it leaves a.png's placement alone. + std::fs::write(&b, png(20, 10)).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + let after = rows(&mut s); + assert_eq!(paths(&after), paths(&before)); + assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10]))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pasted_graphics_reuse_the_identical_asset_of_the_target() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef}; + let asset = |id: u64, data: Vec| Asset { + id: AssetId(id), + name: "p.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(data), + pixels: None, + page: 0, + }; + let mut src = Document::new(&NewDocument::default()); + let mut dst = Document::new(&NewDocument::default()); + let (pa, pb) = (png(4, 4), png(8, 4)); + // The source's asset id is taken in the target by a different file. + let sid = AssetId(src.alloc()); + src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone()))); + dst.assets.insert(sid, Arc::new(asset(sid.0, pb))); + dst.alloc(); + let same = AssetId(dst.alloc()); + assert_ne!(same, sid); + dst.assets.insert(same, Arc::new(asset(same.0, pa))); + let iid = ItemId(src.alloc()); + let mut it = Item::new( + iid, + src.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), + ); + it.content = Content::Graphic(Graphic { + asset: sid, + size: (4.0, 4.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [1.0; 4], + }); + src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); + let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap(); + assert_eq!((g.asset, g.crop), (same, [1.0; 4])); + assert_eq!(dst.assets.len(), 2); + } +} diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 604369df..879fdb36 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -168,7 +168,7 @@ pub fn specs() -> Vec { } ), cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| { - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "guide.liquid")?; let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize; let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index 791a0d70..d9ce5114 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -13,11 +13,16 @@ use crate::{Result, Session}; fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item { let (svg, (w, h)) = crate::math::to_svg(latex, size); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }), - ); + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { + page: 0, + id: fresh, + name: "math.svg".into(), + mime: "image/svg+xml".into(), + link: None, + data: Arc::new(svg), + pixels: None, + }); let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); @@ -65,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { }); } let text = s.doc()?.selection.text.filter(|t| t.cell.is_none()); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "math.insert")?; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); s.edit(|d, sel| { if let Some(t) = text { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b8008958..f6d13ec0 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -20,13 +20,13 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); let mime = mime.to_string(); s.edit(|d, sel| { - let aid = AssetId(d.alloc()); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None })); + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None }); let id = match target { Some(fid) => { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; @@ -90,10 +90,9 @@ pub fn specs() -> Vec { s.edit(|d, _| { let poster_id = match poster { Some(Some((bytes, name, link, px))) => { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })); - Some(Some(aid)) + Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }))) } Some(None) => Some(None), None => None, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 68061af0..31e4544f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -254,12 +254,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option { let f = |i: usize| a.get(i).and_then(Value::as_f64); Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?)) } -/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0). -pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef { - match p.get(key) { - Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize), - Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)), - None => SpreadRef::Doc(0), +/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`) +/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent +/// spread got its items on the first page without a word. +pub(crate) fn spread_param(p: &Value, command: &str) -> Result { + let index = |v: &Value, key: &str| { + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer"))) + }; + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(index(value, "spread")?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?; + let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?; + match object.get("kind").and_then(Value::as_str) { + Some("doc") => Ok(SpreadRef::Doc(i)), + Some("parent") => Ok(SpreadRef::Parent(i)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), } } diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 4d7b0f13..c17dc6cd 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -3,7 +3,7 @@ use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { } fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; @@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27..aa5a2cc0 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -14,6 +14,17 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets}; use crate::{Result, Session}; +/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names +/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case). +fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> { + if doc.swatch(name).is_some() { + return Ok(()); + } + let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase(); + let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default(); + Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name"))) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -21,12 +32,20 @@ pub fn specs() -> Vec { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", + "{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create), - cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move), + cmd!( + "transform.move", + "Move", + ["Object", "Transform"], + None, + "{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}", + has_selection, + transform_move + ), cmd!( "transform.resize", "Resize", @@ -160,11 +179,30 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| { + cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); + known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), + cmd!( + "object.swapFillStroke", + "Swap Fill and Stroke", + [], + None, + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, swap_fill_stroke) + ), + cmd!( + "object.defaultFillStroke", + "Default Fill and Stroke", + [], + None, + "{ids?} — no fill and a black stroke; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, default_fill_stroke) + ), cmd!( "object.stroke", "Stroke", @@ -173,6 +211,11 @@ pub fn specs() -> Vec { "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", has_selection, |s, p| { + for key in ["swatch", "gapSwatch"] { + if let Some(name) = p.get(key).and_then(Value::as_str) { + known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; + } + } let p2 = p.clone(); set_flag( s, @@ -565,13 +608,39 @@ pub fn specs() -> Vec { "Step and Repeat…", ["Edit"], Some("Cmd+Alt+U"), - "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents", + "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}", has_selection, step_and_repeat ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), - cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), - cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), + cmd!( + "path.appendAnchor", + "Add Anchor", + [], + None, + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + has_doc, + path_append + ), + cmd!( + "path.setAnchor", + "Set Anchor", + [], + None, + "{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth", + has_doc, + path_set_anchor + ), + cmd!( + "path.reverse", + "Reverse Path", + ["Object", "Paths"], + None, + "{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)", + has_selection, + path_reverse + ), + cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), cmd!( "object.align", @@ -803,7 +872,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?; let shape = str_param(p, "shape").unwrap_or("rectangle"); let content = str_param(p, "content").unwrap_or("graphic"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "frame.create")?; let lid = s.doc()?.active_layer; let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); @@ -866,7 +935,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P fn line_create(s: &mut Session, p: &Value) -> Result { let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?; let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "line.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -900,6 +969,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { it.id = ItemId(dst.alloc()); let my_id = it.id; + if let Some(m) = it.media.as_mut() + && let Some(p) = m.poster + { + m.poster = dst.adopt_asset(src, p).or(Some(p)); + } match &mut it.content { Content::Text(tf) => { let new_sid = match stories.get(&tf.story) { @@ -908,6 +982,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has let n = StoryId(dst.alloc()); let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); st.id = n; + let mut assets = HashMap::new(); + for a in designcraft_doc::story_asset_refs(&st) { + if let Some(na) = dst.adopt_asset(src, a) + && na != a + { + assets.insert(a, na); + } + } + designcraft_doc::remap_story_assets(&mut st, &assets); st.frames.clear(); dst.stories.insert(n, Arc::new(st)); stories.insert(tf.story, n); @@ -920,10 +1003,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has } } Content::Graphic(g) => { - if let Some(a) = src.assets.get(&g.asset) - && !dst.assets.contains_key(&g.asset) - { - dst.assets.insert(g.asset, a.clone()); + if let Some(a) = dst.adopt_asset(src, g.asset) { + g.asset = a; } } Content::Group { items } => { @@ -969,7 +1050,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { } it.xf = Affine::translate((dx, dy)) * it.xf; } - Ok(json!({"moved": ids.len()})) + Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::>()})) }) } @@ -1281,6 +1362,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let to = str_param(p, "to").unwrap_or("front").to_string(); s.edit(|d, _| { + if matches!(to.as_str(), "forward" | "backward") { + let selected: HashSet = ids.iter().copied().collect(); + let mut positions: Vec<_> = selected + .iter() + .filter_map(|id| { + let loc = d.find(*id)?; + match loc.path.as_slice() { + [i] => Some((loc.spread, *i)), + _ => None, + } + }) + .collect(); + // Move the leading selected items first, independently of selection/click order. + // Swapping only with unselected siblings leaves the other selected indices valid. + positions.sort_by_key(|(_, i)| *i); + let forward = to == "forward"; + if forward { + positions.reverse(); + } + for (spread, i) in positions { + let Some(sp) = d.spread_mut(spread) else { continue }; + let Some(it) = sp.items.get(i) else { continue }; + let layer = it.layer; + let sibling = if forward { + sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer) + } else { + sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer) + }; + if let Some((j, it)) = sibling + && !selected.contains(&it.id) + { + // Keep every other layer's slots and relative stacking order untouched. + sp.items.swap(i, j); + } + } + return ok(); + } for id in &ids { let Some(loc) = d.find(*id) else { continue }; if loc.path.len() != 1 { @@ -1293,7 +1411,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let j = match to.as_str() { "front" => n - 1, "back" => 0, - "forward" => (i + 1).min(n - 1), _ => i.saturating_sub(1), }; sp.items.insert(j, it); @@ -1367,6 +1484,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// An object a colour command may change: neither it nor its layer is locked. +fn unlocked(d: &Document, it: &Item) -> bool { + !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) +} + +fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; + if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) { + Ok(()) + } else { + Err("the selected objects are locked".into()) + } +} + +/// Runs `f` on each unlocked target in one edit (one undo step). +fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + if d.item(*id).is_some_and(|it| unlocked(d, it)) + && let Some(it) = d.item_mut(*id) + { + f(it); + changed += 1; + } + } + Ok(json!({"changed": changed})) + }) +} + +/// A stroke that gets a colour needs a weight to show it. +fn visible_weight(st: &mut Stroke) { + if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 { + st.weight = Stroke::default().weight; + } +} + +fn swap_fill_stroke(it: &mut Item) { + let fill = std::mem::take(&mut it.fill); + it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() }; + it.stroke.swatch = fill.swatch; + it.stroke.tint = fill.tint; + visible_weight(&mut it.stroke); +} + +fn default_fill_stroke(it: &mut Item) { + it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() }; + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.tint = 1.0; + visible_weight(&mut it.stroke); +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result { s.edit(|d, _| { for i in 0..d.spreads.len() { @@ -1555,7 +1726,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .ok_or_else(|| bad(ID, "missing at"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?; // `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts. let delta = @@ -1850,6 +2021,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result { for ((id, r), t) in items.iter().zip(targets) { let delta = t - lo(r); if let Some(it) = d.item_mut(*id) { + // Keep locked items in the spacing calculation, as align does for its reference. + if it.locked { + continue; + } let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }; it.xf = Affine::translate(v) * it.xf; } @@ -1882,7 +2057,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result { return Err(bad("path.create", "a path needs at least two anchors")); } let closed = bool_or(p, "closed", false); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "path.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -1898,11 +2073,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; + let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?; + let sp = match subpath { + Some(i) => { + usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? + } + None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + }; + if sp.closed { + return Err(bad("path.appendAnchor", "the subpath is closed")); + } sp.anchors.push(a.transform(inv)); if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -1911,12 +2095,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result { }) } +fn path_set_anchor(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.setAnchor"; + let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?; + let pt = |k: &str| -> Option { + let a = p.get(k)?.as_array()?; + let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + }; + let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out")); + if new_p.is_none() && new_in.is_none() && new_out.is_none() { + return Err(bad(ID, "give p, in or out")); + } + let subpath = p.get("subpath").and_then(Value::as_u64); + let which = p.get("anchor").cloned().unwrap_or(Value::Null); + s.edit(|d, _| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked { + return Err(bad(ID, "the path is locked")); + } + let sp = match subpath { + Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), + None => it.path.subpaths.last_mut(), + } + .ok_or_else(|| bad(ID, "no such subpath"))?; + let n = sp.anchors.len(); + let ai = match &which { + Value::String(w) if w == "first" => 0, + Value::String(w) if w == "last" => n.saturating_sub(1), + v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?, + }; + let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?; + if let Some(q) = new_p { + let v = inv * q - a.p; + a.p += v; + a.h_in += v; + a.h_out += v; + } + if let Some(q) = new_in { + a.h_in = inv * q; + } + if let Some(q) = new_out { + a.h_out = inv * q; + } + if new_in.is_some() && new_out.is_some() { + let (i, o) = (a.h_in - a.p, a.h_out - a.p); + let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0; + a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner }; + } + ok() + }) +} + +fn path_reverse(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); + if let (Some(i), Some(id)) = (only, ids.first()) + && s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none()) + { + return Err(bad("path.reverse", "no such subpath")); + } + let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + s.edit(|d, _| { + let mut reversed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + sp.anchors.reverse(); + for a in &mut sp.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + reversed += 1; + } + Ok(json!({"reversed": reversed})) + }) +} + fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for sp in &mut it.path.subpaths { - sp.closed = true; + if only.is_some_and(|i| i >= it.path.subpaths.len()) { + return Err(bad("path.close", "no such subpath")); + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_none_or(|o| o == i) { + sp.closed = true; + } } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -2079,14 +2354,195 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { } } *sel = Selection::items(all.clone()); - Ok(json!({"created": all.len() - ids.len()})) + let copies: Vec = all.iter().skip(ids.len()).map(|i| i.0).collect(); + Ok(json!({"created": copies.len(), "ids": copies})) }) } +#[cfg(test)] +mod arrange_tests { + use super::*; + + fn fixture(layers: [usize; N]) -> (Session, [ItemId; N]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let first = s.doc().unwrap().doc.default_layer().0; + let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap(); + let layer_ids = [first, second]; + let mut ids = [ItemId(0); N]; + for (id, layer) in ids.iter_mut().zip(layers) { + // Overlap objects within each layer; separate layers spatially for hit testing. + let x = if layer == 0 { 0 } else { 200 }; + *id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap(); + } + (s, ids) + } + + fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect() + } + + fn snapshot(s: &Session) -> Value { + let st = s.doc().unwrap(); + json!({"document": &*st.doc, "selection": &st.selection}) + } + + fn assert_items_unchanged(before: &Document, after: &Document) { + assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap()); + for it in &before.spreads[0].items { + assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap()); + } + } + + #[test] + fn arrange_steps_past_other_layers_change_hit_order_and_undo() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]); + let selected = if direction == "forward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + let before = snapshot(&s); + let doc = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0); + assert_eq!(hit(&s), Some(b)); + + s.execute("object.arrange", &json!({"to": direction})).unwrap(); + assert_eq!(stack(&s), [b, x, y, a], "{direction}"); + assert_eq!(hit(&s), Some(a), "the visible topmost object must change"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection.items, [selected]); + let after = snapshot(&s); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), before); + assert_eq!(hit(&s), Some(b)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), after); + assert_eq!(hit(&s), Some(a)); + } + } + + #[test] + fn arrange_multiple_selection_preserves_order_independent_of_input_order() { + // Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices. + let cases = [ + ("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block + ("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block + ("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks + ("backward", [2, 5], [2, 1, 0, 3, 5, 4]), + ("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint + ("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint + ]; + for (direction, selected, expected) in cases { + for reverse in [false, true] { + let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]); + let doc = s.doc().unwrap().doc.clone(); + let mut selected = selected.map(|i| ids[i].0).to_vec(); + if reverse { + selected.reverse(); + } + // Explicit command targets may contain duplicates: an item still moves once. + selected.push(selected[0]); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + } + } + } + + #[test] + fn arrange_same_layer_steps_stop_at_layer_endpoints() { + let cases = [ + ("forward", vec![1], [0, 2, 1, 3, 4]), + ("backward", vec![3], [0, 1, 3, 2, 4]), + ("forward", vec![3], [0, 1, 2, 3, 4]), + ("backward", vec![1], [0, 1, 2, 3, 4]), + ("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ]; + for (direction, selected, expected) in cases { + let (mut s, ids) = fixture([1, 0, 0, 0, 1]); + let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect(); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i])); + } + for direction in ["forward", "backward"] { + let (mut s, [x, a, y]) = fixture([1, 0, 1]); + s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap(); + assert_eq!(stack(&s), [x, a, y], "only object on its layer"); + } + } + + #[test] + fn arrange_selection_on_two_layers_moves_each_layer_independently() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]); + let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] }; + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), [b, y, a, x]); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x)); + } + } + + #[test] + fn arrange_menu_steps_use_same_layer_order() { + for command in ["object.bringForward", "object.sendBackward"] { + let (mut s, [a, x, b]) = fixture([0, 1, 0]); + let selected = if command == "object.bringForward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert_eq!(stack(&s), [b, x, a]); + assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + } + } +} + #[cfg(test)] mod step_repeat_tests { use super::*; + #[test] + fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |v: Value| ItemId(v["id"].as_u64().unwrap()); + let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()); + let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()); + let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()); + s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap(); + s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap(); + let colors = |s: &Session, i: ItemId| { + let it = s.doc().unwrap().doc.item(i).unwrap().clone(); + (it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint) + }; + let before = [colors(&s, a), colors(&s, b), colors(&s, locked)]; + + assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2); + assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5)); + assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both"); + + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + for i in [a, b] { + assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before); + + s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap(); + assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change"); + } + #[test] fn step_repeat_validates_counts_and_offsets() { assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); @@ -2151,7 +2607,7 @@ mod scale_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap(); let id = ItemId(r["id"].as_u64().unwrap()); - s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap(); + s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap(); let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight; let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds(); s.execute("transform.scale", &json!({"sx": 2.0})).unwrap(); @@ -2463,8 +2919,8 @@ mod fitting_tests { s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap(); let c = content(&s); assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}"); - // Crop 10 pt on every side: the content spreads past the frame. - s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap(); + // Crop 10 pt on every side and fit again: the content spreads past the frame. + s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap(); let c = content(&s); assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}"); // Auto-fit: resizing the frame refits the content. @@ -2566,6 +3022,18 @@ mod line_end_tests { s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap(); assert_eq!(story(&s).text, "One\nTwo\nThree\nFour"); assert_eq!(story(&s).paras.len(), 4); + // Editing a range or the whole story (scripts written for InDesign end paragraphs with \r). + s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); + // Notes, made and edited. + s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap(); + let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap(); + let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone(); + assert_eq!(note(&mut s), "One\nTwo"); + s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); + assert_eq!(note(&mut s), "Three\nFour"); } } @@ -2714,3 +3182,148 @@ mod blend_mode_tests { assert_eq!(it.opacity, 1.0); } } + +#[cfg(test)] +mod copy_ids_tests { + use serde_json::json; + + #[test] + fn copies_report_their_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::>(); + let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::>(); + // Moving reports what moved; copying reports the copies, which are selected. + assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]); + let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap(); + let copy = ids(&r); + assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}"); + assert_eq!(selected(&s), copy); + // Edit › Duplicate. + let r = s.execute("edit.duplicate", &json!({})).unwrap(); + let dup = ids(&r); + assert!(dup.len() == 1 && dup[0] != copy[0], "{r}"); + assert_eq!(selected(&s), dup); + // Step and Repeat: every copy. + let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap(); + assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}"); + assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some())); + } +} + +#[cfg(test)] +mod swatch_name_tests { + use serde_json::json; + + #[test] + fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone(); + for (cmd, params, hint) in [ + ("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")), + ("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")), + ("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None), + ("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None), + ("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")), + ] { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}"); + if let Some(h) = hint { + assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed"); + } + // Real names, [None] included, still work. + for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] { + s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap(); + } + } +} + +#[cfg(test)] +mod path_edit_tests { + use designcraft_doc::ItemId; + use designcraft_geom::Point; + use serde_json::json; + + fn session_with_path() -> (crate::Session, u64) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute( + "path.create", + &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + (s, id) + } + + fn anchors(s: &crate::Session, id: u64) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf; + it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect() + } + + #[test] + fn reverse_path_swaps_ends_and_direction_lines() { + let (mut s, id) = session_with_path(); + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + let a = anchors(&s, id); + assert_eq!(a.iter().map(|a| a.p).collect::>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0))); + assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err()); + } + + #[test] + fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() { + let (mut s, id) = session_with_path(); + s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); + assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // Direction lines on the last anchor: collinear and opposite make it smooth. + s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); + let last = *anchors(&s, id).last().unwrap(); + assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth)); + s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap(); + assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0)); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err()); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set"); + // Closing one subpath; then it takes no more anchors. + s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap(); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); + assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); + } +} + +#[cfg(test)] +mod spread_target_tests { + use super::*; + + #[test] + fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0)); + s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2); + + // Each of these used to land on the first body page. + for bad in + [json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])] + { + assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}"); + assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}"); + } + assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); + } +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 46866751..25f09537 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -285,13 +285,13 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset let mut out = d.clone(); let mut taken = Vec::new(); let mut written = Vec::new(); - let mut ids: Vec = d.assets.keys().copied().collect(); - ids.sort_by_key(|i| i.0); + // Only files something places; the saved document leaves the others out. + let ids = d.used_assets(); for id in ids { if only.is_some_and(|o| !o.contains(&id)) { continue; } - let a = &d.assets[&id]; + let Some(a) = d.assets.get(&id) else { continue }; let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); root.write(&path, a.data.as_slice())?; diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87a..ffd852f9 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}", + "{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path", has_doc, path_options ), @@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result> { fn on_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; - let text = super::str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let mut pt = PathType { start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), @@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { let p = p.clone(); s.edit(|d, sel| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length(); let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) }; let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) }; if delete { @@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result { sel.text = None; return Ok(json!({"deleted": true})); } - if let Some(v) = p.get("start").and_then(Value::as_f64) { - pt.start = v.max(0.0); + if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { + pt.start = v.max(0.0).min(len); + } + match p.get("end") { + Some(Value::Null) => pt.end = None, + Some(v) => { + let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?; + pt.end = (e < len - 0.01).then_some(e.max(0.0)); + } + None => {} + } + // The brackets can't cross: the one being moved stops at the other. + if let Some(e) = pt.end + && pt.start > e + { + if p.get("end").is_some() { + pt.end = Some(pt.start); + } else { + pt.start = e; + } } if let Some(v) = p.get("flip").and_then(Value::as_bool) { pt.flip = v; @@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { if let Some(a) = align { pt.align = a; } - Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align})) + Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align})) }) } @@ -160,4 +179,93 @@ mod tests { let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap(); assert!(pdf.len() > 1000); } + + #[test] + fn the_brackets_set_where_the_text_starts_and_ends() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(!overset(&s)); + // The end bracket close to the start: the text no longer fits, and is overset. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0))); + assert!(overset(&s)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Brackets can't cross: the start stops at the end, the end at the start. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap(); + assert_eq!(r["start"].as_f64(), Some(70.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0))); + // Past the path, or null: the path's end again; the start stays on the path. + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap(); + assert!(r["end"].is_null()); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap(); + assert_eq!(r["end"].as_f64(), Some(200.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true)); + assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err()); + // Saved with the document. + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap(); + let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); + assert_eq!(path(&back), path(&d)); + } + + #[test] + fn the_type_tool_clicks_and_selects_along_a_curved_path() { + for flip in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // An arch: up from (100, 400) over (300, 200) and down to (500, 400). + let id = s + .execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // Where each glyph sits on the path, from the composed line. + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let ft = &cs.frames[0]; + let map = designcraft_compose::PathTextMap::new(it, ft).unwrap(); + let l = &ft.lines[0]; + let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let on_path = |byte: usize| { + let g = glyph(byte); + // A little outside the path, as a click near the letter would be. + map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0)) + }; + // A click by the first C (byte 10), on the curve: the caret goes there. + let p = on_path(10); + let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap(); + assert_eq!(r["pos"], 10, "flip={flip}: {r}"); + // Dragging to the first E (byte 20) selects C to D. + let q = on_path(20); + s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.range(), 10..20, "flip={flip}"); + // A double-click on a word (its first click places the caret) selects it. + let w = on_path(16); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}"); + // A click on a letter, above the path, hits the frame (the type tool's hit test). + let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7)); + let sp = s.doc().unwrap().doc.clone(); + assert_eq!( + sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0), + Some(designcraft_doc::ItemId(id)), + "flip={flip}" + ); + // The map runs both ways along the path. + let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline))); + assert!((back.x - 123.0).abs() < 0.01, "{back:?}"); + } + } } diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7..f5ee8dfd 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -5,6 +5,7 @@ use designcraft_compose::ComposedStory; use designcraft_doc::{Content, Document, Intent, Item, SpreadRef}; use designcraft_fonts::FaceRef; +use designcraft_geom::Affine; use serde::Serialize; use serde_json::{Value, json}; @@ -29,10 +30,12 @@ pub fn specs() -> Vec { "{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)] } -fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option { +fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option { let SpreadRef::Doc(si) = sr else { return None }; let sp = d.spreads.get(si)?; - Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0)) + // Item bounds include its own transform; ancestors bring the frame into spread space. + let bounds = parent.transform_rect_bbox(it.bounds()); + Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0)) } /// Characters a missing-glyph report names before "and N more". @@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { page: None, }); } - // Items. + // Carry the ancestor transform in the same order as rendering, retaining pre-order. for (si, sp) in d.spreads.iter().enumerate() { - for top in &sp.items { - top.walk(&mut |it| { - let page = page_of(d, SpreadRef::Doc(si), it); - match &it.content { - Content::Graphic(g) => match d.assets.get(&g.asset) { - None => { - out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }) - } - Some(a) if a.data.is_empty() => out.push(Issue { - severity: "error", - kind: "missingLink", - message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), - item: Some(it.id.0), - page, - }), - Some(a) => { - // Effective ppi: pixels per inch at the placed size. Placed PDF and - // SVG pages export as vectors; their `pixels` is only the preview's. - let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); - if let Some((pw, _)) = a.pixels.filter(|_| !vector) { - let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0; - let ppi = pw as f64 / (placed_w / 72.0).max(1e-6); - if d.settings.intent == Intent::Print && ppi < min_ppi { - out.push(Issue { - severity: "warning", - kind: "lowResolution", - message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), - item: Some(it.id.0), - page, - }); - } + let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect(); + while let Some((it, parent)) = pending.pop() { + let xf = parent * it.xf; + pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); + let page = page_of(d, SpreadRef::Doc(si), it, parent); + match &it.content { + Content::Graphic(g) => match d.assets.get(&g.asset) { + None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), + Some(a) if a.data.is_empty() => out.push(Issue { + severity: "error", + kind: "missingLink", + message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + item: Some(it.id.0), + page, + }), + Some(a) => { + // Placed PDF and SVG pages export as vectors; `pixels` is only the preview's. + let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); + if let Some((pw, ph)) = a.pixels.filter(|_| !vector) { + // Measure each image axis in points, not its rotated bounding box or + // cropped frame. Either axis can fall below the minimum resolution. + let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs(); + let placed_w = xx.hypot(yx) * g.size.0; + let placed_h = xy.hypot(yy) * g.size.1; + let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6); + let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6); + let ppi = ppi_x.min(ppi_y); + if d.settings.intent == Intent::Print && ppi < min_ppi { + out.push(Issue { + severity: "warning", + kind: "lowResolution", + message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), + item: Some(it.id.0), + page, + }); } } - }, - Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { - out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - _ => {} + }, + Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { + out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - }); + _ => {} + } } } // RGB swatches in use in a print document. diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 724feb2b..659b520e 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,7 +1,4 @@ -//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF. - -#[cfg(not(target_arch = "wasm32"))] -use std::io::Write; +//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF. use serde_json::{Value, json}; @@ -20,91 +17,60 @@ pub fn specs() -> Vec { "Print…", ["File"], Some("Cmd+P"), - "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}", + "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}", has_doc, print ), ] } -#[cfg(not(target_arch = "wasm32"))] fn printers() -> Value { - let run = |args: &[&str]| { - std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default() - }; - let names: Vec = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(); - // "system default destination: NAME" - let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string); + let printers = craft_print::printers(); + let default = printers.iter().find(|p| p.default).map(|p| p.name.clone()); + let names: Vec<_> = printers.into_iter().map(|p| p.name).collect(); json!({"printers": names, "default": default}) } -#[cfg(target_arch = "wasm32")] -fn printers() -> Value { - json!({"printers": [], "default": null}) -} - fn print(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let opts = super::export::options(p, d.page_count())?; #[cfg(target_arch = "wasm32")] { - // Even a dry run cannot construct a native spool command in the browser. - let _ = opts; + super::export::options(p, s.doc()?.doc.page_count())?; Err(bad("file.print", "printing isn't available on the web: export a PDF and print it")) } #[cfg(not(target_arch = "wasm32"))] - { - let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); - let printer = str_param(p, "printer").map(str::to_string); - let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let mut file = secure_print_file()?; - let file_path = file.path().to_path_buf(); - let mut cmd: Vec = vec!["lpr".into()]; - if let Some(pr) = &printer { - cmd.extend(["-P".into(), pr.clone()]); - } - if copies > 1 { - cmd.extend(["-#".into(), copies.to_string()]); - } - cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]); - let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); - let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); - if dry { - return Ok(out); - } - file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; - file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; - let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?; - if !st.status.success() { - return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string())); - } - Ok(out) - } + print_with(s, p, craft_print::submit) } #[cfg(not(target_arch = "wasm32"))] -fn secure_print_file() -> Result { - let file = tempfile::Builder::new() - .prefix("designcraft-print-") - .suffix(".pdf") - .tempfile() - .map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.as_file() - .set_permissions(std::fs::Permissions::from_mode(0o600)) - .map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; +fn print_with( + s: &mut Session, + p: &Value, + submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result, +) -> Result { + let d = &s.doc()?.doc; + let opts = super::export::options(p, d.page_count())?; + let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32; + let job = craft_print::Job { + printer: str_param(p, "printer").map(str::to_string), + copies, + scaling: craft_print::Scaling::ShrinkToFit, + title: d.title.clone(), + ..craft_print::Job::default() + }; + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?; + let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); + let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()}); + if !dry { + submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?; } - Ok(file) + Ok(out) } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { - use serde_json::json; - - use crate::Session; + use super::*; #[test] fn print_builds_the_spool_command() { @@ -112,37 +78,45 @@ mod tests { s.execute("file.new", &json!({"pages": 4})).unwrap(); let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap(); assert_eq!(r["pages"], 2); - let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]); - assert!(cmd.last().unwrap().ends_with(".pdf")); - assert!(!std::path::Path::new(cmd.last().unwrap()).exists()); + let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + if craft_print::backend() == Some(craft_print::Backend::Cups) { + assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]); + assert!(command.contains(&"print-scaling=auto-fit")); + assert!(!command.iter().any(|a| a.starts_with("sides="))); + } else { + assert_eq!(command[0], "powershell"); + } assert!(r["bytes"].as_u64().unwrap() > 500); assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err()); assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array()); } - #[cfg(not(target_arch = "wasm32"))] #[test] - fn print_uses_distinct_ephemeral_files() { - let first = super::secure_print_file().unwrap(); - let second = super::secure_print_file().unwrap(); - let first_path = first.path().to_path_buf(); - let second_path = second.path().to_path_buf(); - assert_ne!(first_path, second_path); - drop((first, second)); - assert!(!first_path.exists()); - assert!(!second_path.exists()); - } - - #[cfg(all(not(target_arch = "wasm32"), unix))] - #[test] - fn print_file_is_private_and_removed_on_drop() { - use std::os::unix::fs::PermissionsExt; - - let file = super::secure_print_file().unwrap(); - let path = file.path().to_path_buf(); - assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600); - drop(file); - assert!(!path.exists()); + fn print_submits_the_selected_export_and_reports_refusal() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true}); + let expected = { + let d = &s.doc().unwrap().doc; + let options = super::super::export::options(¶ms, d.page_count()).unwrap(); + designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap() + }; + let r = print_with(&mut s, ¶ms, |pdf, job| { + // The production export options and full bytes reach the shared spooler boundary. + assert_eq!(pdf, expected); + assert!(pdf.starts_with(b"%PDF-")); + assert_eq!(job.printer.as_deref(), Some("Office (east)")); + assert_eq!(job.copies, 2); + assert_eq!(job.duplex, None); + assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit); + assert!(!job.title.is_empty()); + Ok("request id is Office-1".into()) + }) + .unwrap(); + assert_eq!(r["pages"], 2); + let refusal = print_with(&mut s, ¶ms, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err(); + assert!(refusal.to_string().contains("printer offline")); + print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap(); + assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err()); } } diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs index c201de4e..d68f5e69 100644 --- a/crates/engine/src/cmd/qr.rs +++ b/crates/engine/src/cmd/qr.rs @@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result { let text = encode(p).map_err(|e| bad(ID, e))?; let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string(); let rect = rect_param(p, "rect"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let st = s.doc()?; let lid = st.active_layer; let sel = st.selection.items.clone(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 94bd92c6..c4d20f09 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -180,10 +180,19 @@ pub fn specs() -> Vec { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection", - super::has_selection, + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames", + has_text_or_selection, apply_color ), + cmd!( + "color.sample", + "Sample Color (Eyedropper)", + [], + None, + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + has_text_or_selection, + sample_color + ), cmd!( "swatch.addToSwatches", "Add to Swatches", @@ -444,6 +453,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -470,6 +480,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { @@ -620,7 +631,27 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = super::text::format_targets_pub(s).first() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style", e))?; + } + } + chars = captured; + } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { @@ -641,6 +672,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "[None] can't be edited")); } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p .get("chars") @@ -716,13 +748,18 @@ pub fn parse_color(v: &Value) -> Option { use designcraft_color::Color; match v { Value::String(s) => Color::from_hex(s), + // One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose + // components are all within 0..1 is read as fractions (the older form). Value::Array(a) if a.len() >= 3 => { - let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32); - Some(Color::rgb(f(0)?, f(1)?, f(2)?)) + let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; + let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; + Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) } Value::Object(o) => { - let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32); - Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) + let g = |k: &str| o.get(k).and_then(Value::as_f64); + let v = [g("c")?, g("m")?, g("y")?, g("k")?]; + let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; + Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) } _ => None, } @@ -835,7 +872,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, &bytes); + written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), @@ -942,43 +983,165 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { fn apply_color(s: &mut Session, p: &Value) -> Result { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); - let ids = super::targets(s, p)?; - s.edit(|d, _| { - // Reuse a swatch holding exactly this colour, else add an unnamed one. - let existing = d - .swatches + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + if text { + let targets = super::text::format_targets(s); + // A caret in existing text has no editable range. Refuse it before creating a swatch + // or history; an empty story has a format run that the next insertion inherits. + if !targets .iter() - .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) - .map(|w| w.name.clone()); - let name = match existing { - Some(n) => n, - None => { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name - } - }; - for id in &ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.clone(); - it.stroke.tint = 1.0; + .any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty()))) + { + return Err(bad("object.color", "select the text to colour")); + } + return s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + let set = |f: &mut designcraft_doc::CharFormat| { + if stroke { + (f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0)); + } else { + (f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0)); + } + }; + if t.range.is_empty() { + if story.is_empty() + && let Some(run) = story.chars.first_mut() + { + set(&mut run.format); + story.rev = story.rev.wrapping_add(1); + } } else { - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; + story.format_chars(t.range.clone(), set); } } - } + Ok(json!({"swatch": name})) + }); + } + let ids = super::targets(s, p)?; + s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); Ok(json!({"swatch": name})) }) } +/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches +/// panel until Add to Swatches). +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = super::spread_param(p, "spread")?; + let st = s.doc()?; + let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let hex = color.to_hex(); + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let name = match str_param(p, "swatch") { @@ -1040,6 +1203,168 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session + } + + fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); + } + + #[test] + fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } + } + + #[test] + fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}})); + } + } + } + + #[test] + fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + } + + #[test] + fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); + } + + #[test] + fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } + } + + #[test] + fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = if kind == "paragraph" { + &document.styles.para(name).unwrap().chars + } else { + &document.styles.char_style(name).unwrap().chars + }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } + } + + #[test] + fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } + } + + #[test] + fn percent_components_at_most_one_stay_percent() { + use designcraft_color::Color; + // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. + let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; + assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); + // Fractions (every component within 0..1) keep working. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); + // RGB arrays follow the same rule: [1, 128, 255] is 0..255. + let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); + } + #[test] fn style_groups_rename_every_use() { let mut s = Session::new(); @@ -1318,3 +1643,111 @@ mod cjk_tests { assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = super::super::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); + assert!(caret.contains("select the text"), "{caret}"); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 028971e9..ad306be8 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -178,7 +178,7 @@ fn table_specs() -> Vec { "Split Cell Horizontally", ["Table"], None, - "{} — the target cell becomes two, one above the other", + "{} — each target cell becomes two, one above the other", in_table, |s, p| split(s, p, true) ), @@ -187,7 +187,7 @@ fn table_specs() -> Vec { "Split Cell Vertically", ["Table"], None, - "{} — the target cell becomes two, side by side", + "{} — each target cell becomes two, side by side", in_table, |s, p| split(s, p, false) ), @@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result { fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { let g = target(s, p, "table.split")?; edit_table(s, &g, "table.split", |t| { - t.split_cell(g.range.r0, g.range.c0, horizontal); + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) }) } @@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), None => None, }; - let text = str_param(p, "text").map(str::to_string); + let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); @@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result { } for key in ["row", "story", "table"] { if let Some(v) = p.get(key) - && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) { return Err(bad(ID, format!("{key} must be a non-negative integer"))); } @@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); s.edit(|d, sel| { - let aid = designcraft_doc::AssetId(d.alloc()); + let fresh = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert( - aid, - std::sync::Arc::new(designcraft_doc::Asset { - page: 0, - id: aid, - name, - mime, - link, - data: std::sync::Arc::new(bytes), - pixels: Some((pw, ph)), - }), - ); + let aid = + d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) }); let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let (r, c) = t.owner(g.range.r0, g.range.c0); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4950d82b..b57e9e88 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,6 +31,15 @@ pub fn specs() -> Vec { st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); ok() }), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + select_around(s, p, "text.selectSentence", sentence_bounds) + }), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + select_around(s, p, "text.selectParagraph", para_bounds) + }), + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + select_around(s, p, "text.selectStory", |t, _| (0, t.len())) + }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; @@ -76,7 +85,7 @@ pub fn specs() -> Vec { ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { d.set_story_text(sid, &text)?; if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -97,7 +106,7 @@ pub fn specs() -> Vec { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); @@ -253,7 +262,7 @@ pub fn specs() -> Vec { has_text_or_frames, break_link ), - cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), + cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), ] } @@ -279,6 +288,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Type on a path: the click lands on the line where it is along the path. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) { return Some((sid, b, Some(CellAddr { table, row, col }))); } @@ -627,6 +638,62 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -819,6 +886,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result { format_chars(s, &Value::Object(body)) } +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + if let Some(fill) = &attrs.fill + && document.swatch(fill).is_none() + { + return Err(bad( + command_id, + format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"), + )); + } + Ok(()) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -826,6 +905,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { a.set_json(k, v).map_err(|e| bad("type.char", e))?; } } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); s.edit(|d, _| { @@ -957,6 +1037,17 @@ fn open_type(s: &mut Session, p: &Value) -> Result { Ok(json!({"features": list, "figures": otf::figures(&list)})) } +/// How much of the selected text `type.selectionAttrs` reports. +const SELECTED_TEXT_CHARS: usize = 60; + +fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -967,8 +1058,10 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); + // The selected text (its start, on one line), for previews such as the font menu's. + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) + "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } /// Our own filler text (not Adobe's). @@ -1141,6 +1234,37 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + #[test] fn open_type_toggles_reach_the_text() { let mut s = Session::new(); @@ -1883,3 +2007,44 @@ mod story_query_contract_tests { assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); } } + +#[cfg(test)] +mod multi_click_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index bbcfaf0a..e7772d4e 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's + /// start number renames its pages without changing their index or the number of sections, and + /// the marker composed for a page shows the new name. + #[test] + fn parent_page_number_follows_section_renumbering() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); + let shown = |s: &Session, page: usize| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, 2), 1, "page 3"); + s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); + assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); + } + + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running + /// header is composed for the page it is shown on, not the first page with that name. + #[test] + fn running_header_on_parent_page_with_repeated_page_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); + assert_eq!(names, ["1", "2", "1", "2"]); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // The only heading is on the third page (the second section's page 1). + let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(0), 0, "no heading on or before the first page"); + assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); + assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); + } + #[test] fn running_header_on_parent_page_follows_headings() { let mut s = Session::new(); @@ -158,7 +215,7 @@ mod tests { assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(&d.page_name(page))); + let cs = s.cache.get(&d, sid, Some(page)); cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() }; let (p1, p2, p3) = (shown(0), shown(1), shown(2)); diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 32252a9d..6dab205f 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b1..caeeb80b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +pub mod atomic; pub mod cmd; pub mod dtd; pub mod guard; @@ -91,6 +93,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Recovery entries this state actually opened or published, for repeated-open and cleanup. + /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. + pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, /// Unfilled document while data-merge preview is showing a record. Not saved. pub preview_stash: Option>, /// 1-based record currently previewed. @@ -129,6 +134,7 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + recovery_entries: Vec::new(), preview_stash: None, preview_record: None, fonts: None, @@ -177,6 +183,8 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec, + /// Fonts picked in the font menus, most recent first (shown at the menus' top). + pub recent_fonts: Vec, /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their /// English names instead of their native ones. Documents store the English name either way. pub show_font_names_in_english: bool, @@ -233,6 +241,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + recent_fonts: Vec::new(), show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, @@ -374,8 +383,9 @@ impl Session { self.stop_data_preview(); } #[cfg(not(target_arch = "wasm32"))] - if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { - recovery::discard(dir, d.uid); + { + let recovery_dir = self.recovery_dir.clone(); + recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { self.docs.remove(i); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index a038c2d2..33b9a2a3 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -6,6 +6,11 @@ //! A recovery entry is `.designcraft` (the document) plus `.json` (`{"path", "title", //! "saved"}`: the original file, the title, and when it was written). //! +//! Document uids start at 1 in every process, so each running app keeps its entries in a folder +//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs. +//! An app that starts moves into its own folder what apps that have ended left in theirs, and +//! leaves the folder of one still running alone. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -31,6 +36,295 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery")) } +/// This process's folder in a recovery folder, with the lock that says it is still running. +#[cfg(not(target_arch = "wasm32"))] +struct Run { + root: PathBuf, + dir: PathBuf, + lock: std::fs::File, + #[cfg(unix)] + directory: std::fs::File, +} + +#[cfg(not(target_arch = "wasm32"))] +static RUNS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// The lock in a run's folder. +#[cfg(not(target_arch = "wasm32"))] +const LOCK: &str = "lock"; + +/// This run's own folder in the recovery folder `root`, made on the first call. Document uids +/// start at 1 in every process, so two running apps writing to one folder wrote over each +/// other's entries. The folder's lock is held until the process ends: an app that starts leaves +/// the folder of one that is still running alone. +#[cfg(not(target_arch = "wasm32"))] +pub fn run_dir(root: &Path) -> Result { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(run) = runs.iter().find(|r| r.root == root) { + return Ok(run.dir.clone()); + } + secure_recovery_dir(root)?; + for n in 0..64 { + let dir = root.join(format!("run-{}-{n}", std::process::id())); + // Only a folder this run makes is its own. One that is there is another app's, or was + // left by a run that ended under this process id. + #[cfg(unix)] + let made = { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(&dir) + }; + #[cfg(not(unix))] + let made = std::fs::create_dir(&dir); + match made { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let locked = validate_recovery_dir(&dir).and_then(|()| { + let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + match lock.try_lock() { + Ok(()) => { + validate_lock(&dir, &lock)?; + Ok(Some(lock)) + } + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + }); + match locked { + // An app that is starting can take a folder this new for one a run left, and remove + // it before the lock is held: then the lock is gone, or that app holds it. + Ok(Some(lock)) => { + #[cfg(unix)] + let directory = std::fs::File::open(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + runs.push(Run { + root: root.to_path_buf(), + dir: dir.clone(), + lock, + #[cfg(unix)] + directory, + }); + return Ok(dir); + } + Ok(_) => {} + Err(e) => { + // Never unlink a lock that another starting run may now own. + let _ = std::fs::remove_dir(&dir); + return Err(e); + } + } + } + Err(EngineError::Other(format!("{}: no free folder for this run", root.display()))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn new_lock(dir: &Path) -> std::io::Result { + let mut new = std::fs::OpenOptions::new(); + new.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + new.mode(0o600); + } + let mut file = new.open(dir.join(LOCK))?; + // A marker distinguishes the held handle from a replacement at the same pathname on + // platforms without portable file-identity metadata. Empty older-version locks remain valid. + static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos(); + let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + file.write_all(marker.as_bytes())?; + file.sync_all()?; + Ok(file) +} + +/// Check the current lock name without accepting a symlink or a lock held through an unlinked +/// handle. An advisory lock on an old handle does not protect a replacement at the same name. +#[cfg(not(target_arch = "wasm32"))] +fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> { + let path = dir.join(LOCK); + let fail = || EngineError::Other(format!("{}: this run no longer owns its recovery lock", path.display())); + let before = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&before) || !before.file_type().is_file() { + return Err(fail()); + } + let named = std::fs::File::open(&path).map_err(|_| fail())?; + let held = lock.metadata().map_err(|_| fail())?; + let current = named.metadata().map_err(|_| fail())?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != before.dev() || held.ino() != before.ino() || held.dev() != current.dev() || held.ino() != current.ino() { + return Err(fail()); + } + } + #[cfg(not(unix))] + let _ = (held, current); + if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? { + return Err(fail()); + } + let after = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&after) || !after.file_type().is_file() { + return Err(fail()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != after.dev() || held.ino() != after.ino() { + return Err(fail()); + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn lock_marker(file: &std::fs::File) -> std::io::Result> { + use std::io::{Read, Seek}; + let mut file = file.try_clone()?; + file.seek(std::io::SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.take(129).read_to_end(&mut bytes)?; + if bytes.len() > 128 { + return Err(std::io::Error::other("invalid recovery lock marker")); + } + Ok(bytes) +} + +/// A run's folder that was removed while it runs is made again by the next save: so is its lock. +#[cfg(not(target_arch = "wasm32"))] +fn relock(dir: &Path, created: bool) -> Result<()> { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) }; + validate_recovery_dir(&run.root)?; + #[cfg(unix)] + let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + // Reclaim a newly recreated folder only when this operation actually created it. Existing + // replacements, including ones without a lock, may contain somebody else's documents. + let same_directory = { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = run.directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + held.dev() == directory.dev() && held.ino() == directory.ino() + } + #[cfg(not(unix))] + { + // Without stable directory identity, a valid held lock is the proof of ownership. + // A missing lock cannot authorize an existing folder, even on coarse-timestamp disks. + validate_lock(dir, &run.lock).is_ok() + } + }; + if !created && !same_directory { + return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display()))); + } + match std::fs::symlink_metadata(dir.join(LOCK)) { + Ok(_) => return validate_lock(dir, &run.lock), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let lock = new_lock(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + lock.try_lock().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + validate_lock(dir, &lock)?; + #[cfg(unix)] + let replacement_directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = replacement_directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + if held.dev() != directory.dev() || held.ino() != directory.ino() { + return Err(EngineError::Other(format!("{}: recovery folder changed while restoring its lock", dir.display()))); + } + } + validate_lock(dir, &lock)?; + run.lock = lock; + #[cfg(unix)] + { + run.directory = replacement_directory; + } + Ok(()) +} + +/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs +/// that have ended, and those written straight into the recovery folder before runs had folders. +#[cfg(not(target_arch = "wasm32"))] +fn take_over(own: &Path) { + let root = { + let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter().find(|r| r.dir == own) else { return }; + run.root.clone() + }; + move_entries(&root, own); + let Ok(rd) = std::fs::read_dir(&root) else { return }; + for e in rd.flatten() { + let dir = e.path(); + if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() { + continue; + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let Ok(metadata) = std::fs::symlink_metadata(dir.join(LOCK)) else { continue }; + if is_link_like(&metadata) || !metadata.file_type().is_file() { + continue; + } + let Ok(lock) = std::fs::OpenOptions::new().read(true).write(true).open(dir.join(LOCK)) else { continue }; + match lock.try_lock() { + // Free: the run has ended. Holding it keeps another starting app out. + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => continue, + Err(std::fs::TryLockError::Error(e)) => { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + } + if let Err(e) = validate_lock(&dir, &lock) { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + move_entries(&dir, own); + remove_emptied(&dir); + } +} + +/// Remove the folder of a run that ended, with what the app left in it beside documents. A +/// document still in it, or a file the app did not write, keeps the folder as it is. +#[cfg(not(target_arch = "wasm32"))] +fn remove_emptied(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut left = Vec::new(); + for e in rd { + let Ok(e) = e else { return }; + let path = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::().is_ok()); + // Metadata whose document has gone, and a file a crash left half written. + if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { + left.push(path); + } else if name != LOCK { + return; + } + } + for path in left { + let _ = std::fs::remove_file(path); + } + // The lock last: until then the folder is still that of a run that ended. + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); +} + +#[cfg(not(target_arch = "wasm32"))] +fn move_entries(from: &Path, own: &Path) { + for (uid, _) in list(from) { + let (doc, meta) = files(from, uid); + // Under a uid no document of this run has or gets. + let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + // The document first: its metadata is optional. + if std::fs::rename(&doc, &own_doc).is_ok() { + let _ = std::fs::rename(&meta, &own_meta); + } + } +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } @@ -38,7 +332,7 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { /// Remove a document's recovery entry (it was saved or closed). pub fn discard(dir: &Path, uid: u64) { #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return; } let (d, m) = files(dir, uid); @@ -46,62 +340,172 @@ pub fn discard(dir: &Path, uid: u64) { let _ = std::fs::remove_file(m); } -/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how -/// many were written. +/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory +/// uid may also be another live recovered document's retained source after a failed republish. +pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) { + let Some(doc) = s.docs.get_mut(index) else { return }; + let uid = doc.uid; + // A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale + // source uid must never become a future claim on another document's checkpoint. + let mut entries = std::mem::take(&mut doc.recovery_entries); + if let Some(dir) = default_dir + && !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir)) + { + entries.push((dir.to_path_buf(), uid)); + } + for (dir, uid) in entries { + // Session ownership governs deletion as well as writes: never consume another live + // document's only source merely because its old uid matches this unrelated document. + if !s.docs.iter().enumerate().any(|(other_index, owner)| { + other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir)) + }) { + discard(&dir, uid); + } + } +} + +const MAX_RECOVERY_ENTRIES: usize = 16; + +fn same_directory(a: &Path, b: &Path) -> bool { + a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) +} + +fn has_checkpoint(doc: &DocState, dir: &Path) -> bool { + doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir)) +} + +fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { + for document in &s.docs { + if let Some(owner) = s.docs.iter().find(|owner| { + owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir)) + }) { + return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title()))); + } + } + Ok(()) +} + +fn prepare_save(s: &Session, dir: &Path) -> Result<()> { + // Check before any write or deletion: a failed republish can retain a source whose uid + // coincides with an unrelated live document in a manually configured recovery directory. + protect_retained_sources(s, dir)?; + let created = secure_recovery_dir(dir)?; + #[cfg(not(target_arch = "wasm32"))] + relock(dir, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + Ok(()) +} + +/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many +/// were written. Command callers use the tracked variant without configuring automatic recovery. pub fn save(s: &Session, dir: &Path) -> Result { - secure_recovery_dir(dir)?; + prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); - // Write then rename, so a crash mid-write never leaves a torn file. - write_recovery_file(&doc, &bytes)?; - let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, m.to_string().as_bytes())?; + write_entry(dir, d)?; n += 1; } Ok(n) } +/// Command-path checkpointing also records explicitly selected directories, without configuring +/// automatic recovery. The immutable public helper remains available for fixtures and callers. +pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result { + // Reject the complete batch before writing rather than silently losing old ownership records. + for document in &s.docs { + if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES { + return Err(EngineError::Other( + "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(), + )); + } + } + prepare_save(s, dir)?; + let mut n = 0; + for index in 0..s.docs.len() { + if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { + discard_document(s, index, Some(dir)); + continue; + } + let Some(document) = s.docs.get_mut(index) else { continue }; + let (doc, meta) = files(dir, document.uid); + let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) + .map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + // The primary is useful even if optional metadata fails next. Track it immediately. + if !has_checkpoint(document, dir) { + document.recovery_entries.push((dir.to_path_buf(), document.uid)); + } + let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes())?; + n += 1; + } + Ok(n) +} + +/// Publish a document's recovery entry before its optional metadata. +fn write_entry(dir: &Path, d: &DocState) -> Result<()> { + let (doc, meta) = files(dir, d.uid); + let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes()) +} + #[cfg(not(target_arch = "wasm32"))] -fn secure_recovery_dir(dir: &Path) -> Result<()> { +fn secure_recovery_dir(dir: &Path) -> Result { match std::fs::symlink_metadata(dir) { - Ok(_) => return validate_recovery_dir(dir), + Ok(_) => return validate_recovery_dir(dir).map(|()| false), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), } - // Set the mode while creating directories so existing caller-owned directories are never - // chmodded. DirBuilder applies this mode only to components it creates; the process umask may - // make it more restrictive. + // Create parent directories privately without changing existing caller-owned permissions. + // Create the leaf separately so a creation race can never be mistaken for our own folder. #[cfg(unix)] let create_result = { use std::os::unix::fs::DirBuilderExt; - - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(true).mode(0o700).create(dir) + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) + .map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::DirBuilder::new().mode(0o700).create(dir) }; #[cfg(not(unix))] - let create_result = std::fs::create_dir_all(dir); - - create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; - - // Re-check after creation. This also fails closed if another process won a creation race with - // a symlink or, on Windows, any reparse-point leaf. - validate_recovery_dir(dir) + let create_result = { + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::create_dir(dir) + }; + let created = match create_result { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + }; + validate_recovery_dir(dir)?; + Ok(created) } #[cfg(target_arch = "wasm32")] -fn secure_recovery_dir(_dir: &Path) -> Result<()> { +fn secure_recovery_dir(_dir: &Path) -> Result { Err(EngineError::Other("recovery files are unavailable on the web".into())) } #[cfg(not(target_arch = "wasm32"))] fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { + publish_recovery_file(path, bytes, true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { return Err(EngineError::Other(format!("{} has no parent folder", path.display()))); }; @@ -121,7 +525,8 @@ fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { // Validate the non-followed leaf again immediately before renaming the randomized temporary // file to its fixed sibling destination. validate_recovery_dir(dir)?; - tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; + let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) }; + published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; sync_recovery_dir(dir)?; Ok(()) } @@ -181,11 +586,50 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } +#[cfg(target_arch = "wasm32")] +fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + +/// Canonical keys are only for alias comparison; operational paths stay unchanged so run +/// ownership checks still find the directory registered by the native profile. +fn directory_key(dir: &Path) -> Result { + std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display()))) +} + +/// Include metadata-only entries as occupied, so publication cannot attach a new document to +/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator. +fn occupied_uids(dir: &Path) -> Result> { + let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut ids = std::collections::HashSet::new(); + for entry in entries { + let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let path = entry.path(); + if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + { + ids.insert(uid); + } + } + Ok(ids) +} + +fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet) -> Result<()> { + // At most occupied.len() consecutive candidates can collide. The extra attempt must be free. + for _ in 0..=occupied.len() { + if occupied.insert(state.uid) { + return Ok(()); + } + state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Err(EngineError::Other("no free recovery document uid".into())) +} + /// Recovery entries in `dir`: (uid, metadata). pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return out; } let Ok(rd) = std::fs::read_dir(dir) else { return out }; @@ -207,13 +651,46 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { out } -/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session -/// writes its own). Returns the opened documents' indices. +/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new +/// uid before removing the original, so another crash before the next timer preserves the edits. +/// Entries belonging to documents already open in this session's recovery folder are left alone. +/// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { + open_with_publisher(s, dir, |path, bytes| publish_recovery_file(path, bytes, false)) +} + +fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result> { #[cfg(not(target_arch = "wasm32"))] - validate_recovery_dir(dir)?; + { + validate_recovery_dir(dir)?; + relock(dir, false)?; + } + let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + let created = secure_recovery_dir(&destination)?; + #[cfg(not(target_arch = "wasm32"))] + relock(&destination, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + let source_key = directory_key(dir)?; + let destination_key = directory_key(&destination)?; let mut opened = Vec::new(); - for (uid, meta) in list(dir) { + // For this run's own folder, first bring in what runs that ended left in theirs. + #[cfg(not(target_arch = "wasm32"))] + take_over(dir); + let entries = list(dir); + // Reserve every source entry before opening the first one: publication must never overwrite + // a source still waiting in this list, even after a fresh process restarts its allocator at 1. + let mut occupied = occupied_uids(dir)?; + if source_key != destination_key { + occupied.extend(occupied_uids(&destination)?); + } + occupied.extend(s.docs.iter().map(|d| d.uid)); + for (uid, meta) in entries { + if s.docs.iter().any(|d| { + d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key)) + }) { + continue; + } #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; let (doc, _) = files(dir, uid); @@ -226,12 +703,37 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { // Skipped font files are logged; the document opens without them. let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0); let mut st = DocState::new(d, path); + reserve_uid(&mut st, &mut occupied)?; + st.recovery_entries.push((dir.to_path_buf(), uid)); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; - opened.push(s.add_document(st)); - discard(dir, uid); + let index = s.add_document(st); + opened.push(index); + // New uids are published without replacement, and the original is consumed only after + // both files succeed. Track a published primary even if metadata fails, so Save/Close can + // clean up every snapshot this document actually owns rather than an unrelated same uid. + if let Some(state) = s.docs.get_mut(index) { + let (document, metadata) = files(&destination, state.uid); + let published = designcraft_format::save(state.doc.as_ref()) + .map_err(|e| EngineError::Other(e.to_string())) + .and_then(|bytes| publish(&document, &bytes)); + match published { + Ok(()) => { + state.recovery_entries.push((destination.clone(), state.uid)); + let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); + match publish(&metadata, meta.to_string().as_bytes()) { + Ok(()) => { + discard(dir, uid); + state.recovery_entries = vec![(destination.clone(), state.uid)]; + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } } Ok(opened) } @@ -265,7 +767,8 @@ mod tests { let st = s2.doc().unwrap(); assert!(st.is_dirty()); assert_eq!(st.doc.spreads[0].items.len(), 1); - assert!(list(&dir).is_empty(), "entries are consumed"); + assert_eq!(list(&dir).iter().map(|e| e.0).collect::>(), [st.uid], "the entry follows its reopened document"); + discard(&dir, st.uid); // Saving a document drops its entry. s.recovery_dir = Some(dir.clone()); assert_eq!(save(&s, &dir).unwrap(), 1); @@ -275,6 +778,69 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); + } + + #[test] + fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); + } + + /// Directory collisions fail even for privileged runners. Creating the blocker in the + /// publisher occurs after UID reservation, which would otherwise skip an occupied path. + fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened + } + + #[test] + fn a_failed_recovery_rewrite_preserves_the_original() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + let mut reopened = Session::new(); + let result = open_with_failed_republication(&mut reopened, dir.path()); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); + } + #[cfg(unix)] #[test] fn recovery_data_is_private_to_the_user() { @@ -350,6 +916,567 @@ mod tests { assert!(open(&mut Session::new(), &link).is_err()); assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); } + + #[test] + fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + let recovered = open_with_failed_republication(&mut session, root.path()); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) + } + + #[test] + fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); + } + + #[test] + fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!( + std::fs::read(checkpoint).unwrap(), + expected, + "closing the saved recovered document must not delete another document's checkpoint" + ); + } + + #[test] + fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } + } + + fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root + } + + fn persisted_texts(dir: &Path) -> Vec { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result + } + + #[test] + #[ignore = "subprocess helper, executed by the restart recovery regression tests"] + fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } + } + + fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!( + output.status.success(), + "fresh child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); + } + + #[test] + fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); + } + #[test] + fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); + } + #[test] + fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); + } + #[test] + fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); + } + #[test] + fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); + } + #[test] + fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); + } + #[test] + fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); + } + #[test] + fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); + } + #[test] + fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); + } + + /// The recovery folder was cleared while the app runs: the next save makes the run's folder + /// again, and its lock with it. + #[cfg(unix)] + #[test] + fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + } + + /// Replacing the name of a live run's lock must not authorize writes under the old handle. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_was_replaced() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); + } + + /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_cannot_be_recreated() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + std::fs::create_dir(own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty()); + } + + #[cfg(unix)] + #[test] + fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let (doc, meta) = files(&own, 1); + std::fs::write(&doc, b"the replacement owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + discard(&own, 1); + assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); + assert!(meta.exists()); + } + + #[cfg(unix)] + #[test] + fn saving_a_run_rejects_a_symlinked_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let unrelated = root.path().join("unrelated"); + std::fs::write(&unrelated, b"unrelated file").unwrap(); + std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); + } + + #[cfg(unix)] + #[test] + fn a_replaced_run_directory_is_not_claimed_or_overwritten() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + let session = dirty_session(); + let uid = session.docs[0].uid; + std::fs::remove_dir_all(&own).unwrap(); + std::fs::create_dir(&own).unwrap(); + let (doc, _) = files(&own, uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + assert!(save(&session, &own).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); + assert!(!own.join(LOCK).exists()); + } + + #[cfg(unix)] + #[test] + fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + } + + fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session + } + + fn texts(s: &Session) -> Vec { + let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts + } + + /// What a run that has ended leaves: its folder with its lock, free now, and the entry of + /// one unsaved document, numbered `uid` as that process numbered it. + fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir + } + + /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` + /// into the one recovery folder, and only the document written last was recovered. + #[test] + fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); + } + + /// An app that started reopened the unsaved documents of one still running, and removed + /// its entries. + #[test] + fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); + } + + /// A process id comes round again: the folder a run left under it is not this run's. + #[test] + fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); + } + + /// Entries written straight into the recovery folder, as before runs had folders. + #[test] + fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); + } + + /// An entry that can't be opened stays on disk, in this run's folder now. + #[test] + fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); + } + + /// Only a folder named as a run's, with a lock that is free, is a run that ended. + #[test] + fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); + } + + /// The metadata goes with its document: a document taken over remembers its file. + #[test] + fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); + } + + /// The folder of a run that ended goes with what the app left in it. A file the app did not + /// write keeps the folder, and is not deleted. + #[test] + fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); + } + + /// A folder that is there is another app's or was left behind, lock or no lock: a run's own + /// is one it made itself. + #[test] + fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); + } } #[cfg(test)] diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index a447cb6b..d2304e65 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -9,7 +9,8 @@ //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and -//! lines starting with `#` or `//` are ignored. +//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are +//! accepted. use serde_json::{Value, json}; @@ -22,7 +23,9 @@ pub struct Step { /// Parse script text (see the module docs). pub fn parse(text: &str) -> Result, String> { - let t = text.trim(); + // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the + // first command id. + let t = text.trim_start_matches('\u{feff}').trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -174,6 +177,17 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM + /// became part of the first command id (#654). + #[test] + fn a_leading_bom_and_crlf_parse_like_plain_text() { + let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n"; + let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n")); + assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap()); + let array = r#"[{"command": "file.new"}]"#; + assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap()); + } + #[test] fn resolves_references() { let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1032bcc..17d1ed09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() { let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); s.execute("place.load", &json!({"base64": b64})).unwrap(); s.execute("place.drop", &json!({"frame": f})).unwrap(); - assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_))); + let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content { + designcraft_doc::Content::Graphic(g) => g.xf, + _ => panic!("not a graphic"), + }; + let placed = graphic_xf(&s); + // Dragging the top edge down crops the top only: the image stays where it is. + s.execute("selection.set", &json!({"ids": [f]})).unwrap(); + s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap(); + assert_eq!(graphic_xf(&s), placed); } #[test] diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347..3934aabe 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn split_selected_row_or_column_splits_both_cells() { + for horizontal in [true, false] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", ¶ms).unwrap(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + let table = table_for(&s, sid, tid); + assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) }); + for r in 0..2 { + for c in 0..2 { + assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves"); + } + } + check(&s); + } +} + +#[test] +fn split_selected_block_and_merged_cells_preserves_geometry_and_history() { + for horizontal in [true, false] { + for merged in [false, true] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); + s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); + for r in 0..4 { + for c in 0..4 { + s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); + } + } + let range = if !merged { + doc::CellRange::new(1, 1, 2, 2) + } else if horizontal { + doc::CellRange::new(0, 0, 2, 1) + } else { + doc::CellRange::new(0, 0, 1, 2) + }; + let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); + s.execute("table.select", &selection).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); + if merged { + let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; + s.execute("table.merge", &merge).unwrap(); + s.execute("table.select", &selection).unwrap(); + } + let before_doc = s.doc().unwrap().doc.clone(); + let before_selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); + let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); + let after_doc = s.doc().unwrap().doc.clone(); + let after_selection = s.doc().unwrap().selection.clone(); + let after = table_for(&s, sid, tid).clone(); + let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); + let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); + assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); + for placed in &before_fragment.cells { + let original = before.cell(placed.row, placed.col).unwrap(); + let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); + assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); + let surviving = matches[0]; + let cell = after.cell(surviving.row, surviving.col).unwrap(); + let mut normalized = cell.clone(); + normalized.row_span = original.row_span; + normalized.col_span = original.col_span; + assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); + if !range.contains(placed.row, placed.col) { + assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); + continue; + } + let second_addr = if horizontal { + (surviving.row + cell.row_span as usize, surviving.col) + } else { + (surviving.row, surviving.col + cell.col_span as usize) + }; + let second = after.cell(second_addr.0, second_addr.1).unwrap(); + assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); + assert!(second.text.is_empty()); + assert_eq!(second.style, original.style); + assert_eq!(second.insets, original.insets); + assert_eq!(second.fill, original.fill); + assert_eq!(second.fill_tint, original.fill_tint); + let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; + assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); + if horizontal { + assert_eq!(surviving.rect.y1, second_rect.y0); + } else { + assert_eq!(surviving.rect.x1, second_rect.x0); + } + } + let text = s.doc().unwrap().selection.text.unwrap(); + let address = text.cell.unwrap(); + assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); + assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); + let cells = s.doc().unwrap().selection.cells.unwrap(); + assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); + check(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); + assert_eq!(s.doc().unwrap().selection, before_selection); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); + assert_eq!(s.doc().unwrap().selection, after_selection); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let reopened = crate::cmd::file_from(&bytes).unwrap(); + reopened.check().unwrap(); + assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + } + } +} diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/book_safety.rs new file mode 100644 index 00000000..74c96815 --- /dev/null +++ b/crates/engine/tests/book_safety.rs @@ -0,0 +1,188 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; +use std::path::Path; +fn dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap() +} +fn document(start: u32, custom: bool) -> designcraft_doc::Document { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + d.sections.first_mut().unwrap().start_number = Some(start); + if custom { + let mut style = d.styles.paragraph.get(1).unwrap().clone(); + style.name = "Synthetic Book Style".into(); + d.styles_mut().paragraph.push(style); + } + d +} +fn native(p: &Path, start: u32, custom: bool) { + std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap(); +} +fn idml(p: &Path, start: u32) { + std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap(); + assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok()); +} +fn book(d: &Path, chapters: &[&Path]) -> Session { + let mut s = Session::new(); + s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap(); + for p in chapters { + s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap(); + } + s +} +#[test] +fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() { + let d = dir("late-missing"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::remove_file(later).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.paginate changed the first chapter before error: {result:?}; new first number {:?}", + designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number + ); +} +#[test] +fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() { + let d = dir("late-corrupt"); + let source = d.path().join("source.designcraft"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&source, 1, true); + native(&first, 1, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&source, &first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::write(later, b"not a document").unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.syncStyles changed the first target before error: {result:?}; copied custom style {}", + designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style") + ); +} +#[test] +fn paginate_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-paginate"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 9); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.paginate", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} +#[test] +fn style_sync_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-sync"); + let source = d.path().join("source.designcraft"); + let chapter = d.path().join("chapter.idml"); + native(&source, 1, true); + idml(&chapter, 1); + let mut s = book(d.path(), &[&source, &chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} + +#[test] +fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() { + let d = dir("open-unsaved"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 5, false); + let mut s = book(d.path(), &[&first, &later]); + s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap(); + s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let later_before = std::fs::read(&later).unwrap(); + let revision = s.doc().unwrap().revision; + let result = s.execute("book.paginate", &json!({})); + assert!( + result.is_err(), + "book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}", + std::fs::read(&first).unwrap() != first_before, + std::fs::read(&later).unwrap() != later_before + ); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&later).unwrap(), later_before); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(s.doc().unwrap().is_dirty()); + let e = result.unwrap_err().to_string(); + assert!(e.contains("later.designcraft") && e.contains("close"), "{e}"); +} + +#[cfg(unix)] +#[test] +fn an_alias_of_an_open_chapter_blocks_all_batch_writes() { + let d = dir("open-alias"); + let first = d.path().join("first.designcraft"); + let real = d.path().join("real.designcraft"); + let alias = d.path().join("alias.designcraft"); + native(&first, 9, false); + native(&real, 5, false); + std::os::unix::fs::symlink("real.designcraft", &alias).unwrap(); + let mut s = book(d.path(), &[&first, &alias]); + s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let real_before = std::fs::read(&real).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}"); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&real).unwrap(), real_before); + let e = result.unwrap_err().to_string(); + assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}"); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} +#[test] +fn imported_idml_remains_a_read_only_style_source() { + let d = dir("idml-style-source"); + let source = d.path().join("source.idml"); + let target = d.path().join("target.designcraft"); + std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap(); + native(&target, 1, false); + let mut s = book(d.path(), &[&source, &target]); + let before = std::fs::read(&source).unwrap(); + let result = s.execute("book.syncStyles", &json!({})).unwrap(); + assert_eq!(result["synced"], 1); + assert_eq!(std::fs::read(&source).unwrap(), before); + assert!(designcraft_idml::import_idml(&before).is_ok()); + let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap(); + assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")); +} +#[test] +fn imported_idml_still_exports_to_book_pdf_without_source_changes() { + let d = dir("idml-pdf"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 1); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.exportPdf", &json!({})).unwrap(); + assert_eq!(result["pages"], 2); + assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(std::fs::read(chapter).unwrap(), before); +} diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/character_style_from_selection.rs new file mode 100644 index 00000000..0c9d8083 --- /dev/null +++ b/crates/engine/tests/character_style_from_selection.rs @@ -0,0 +1,274 @@ +//! Character Styles panel's existing `fromSelection` command contract. +use designcraft_doc::{CharAttrs, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; + +fn story(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) +} + +fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) { + s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); +} + +fn chars(s: &Session, name: &str) -> CharAttrs { + s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone() +} + +fn document(s: &Session) -> Value { + serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap() +} + +#[test] +fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() { + let (mut s, sid) = story("alpha\nbeta"); + s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap(); + assert_eq!( + chars(&s, "Captured"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() } + ); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(resolved["chars"]["fontStyle"], "Italic"); + assert_eq!(resolved["chars"]["strikethrough"], true); + assert_eq!(resolved["chars"]["underline"], false); + assert_eq!(resolved["chars"]["tracking"], 7.0); + assert_eq!(resolved["chars"]["size"], 30.0); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0); + // The captured attributes are a snapshot, independent of future source-style edits. + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic"); +} + +#[test] +fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() { + let (mut s, sid) = story("alpha beta"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + assert_eq!(s.doc().unwrap().selection, selection); + + // Both commands use the native ZIP format, without a filesystem fixture. + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured")); + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false); + let before_apply = document(&reopened); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta"); + let after_apply = document(&reopened); + reopened.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&reopened), before_apply); + reopened.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&reopened), after_apply); +} + +#[test] +fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() { + let (mut s, sid) = story("éX"); + select(&mut s, sid, 0, 2); + s.execute("type.char", &json!({"underline": true})).unwrap(); + select(&mut s, sid, 2, 3); + s.execute("type.char", &json!({"underline": false})).unwrap(); + // A selected character takes the run on the right; a caret takes the typing run on the left. + // Reversed and mixed selections start at the lowest byte offset, just like paragraph capture. + for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] { + select(&mut s, sid, anchor, focus); + let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap(); + let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap(); + let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap(); + let c = chars(&s, c["name"].as_str().unwrap()); + assert_eq!(c.underline, Some(underlined)); + assert_eq!(c.underline, p.chars.underline); + assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults"); + } +} + +#[test] +fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() { + let (mut s, sid) = story("alpha"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] { + let created = s.execute("style.character.create", &options).unwrap(); + assert!(chars(&s, created["name"].as_str().unwrap()).is_empty()); + } + assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some()); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}})) + .unwrap(); + assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() }); + assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain")); + let (mut empty, empty_sid) = story(""); + select(&mut empty, empty_sid, 0, 0); + empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap(); + assert!(chars(&empty, "Empty").is_empty()); +} + +#[test] +fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha beta gamma"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + } + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + // Non-fill overrides do not turn an existing captured reference into explicit input. + s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}})) + .unwrap(); + assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap(); + assert!(chars(&s, "Cleared fill").is_empty()); + select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + s.doc().unwrap().doc.check().unwrap(); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap(); + reopened.doc().unwrap().doc.check().unwrap(); + for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] { + assert_eq!(chars(&reopened, name), chars(&s, name)); + } + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + select(&mut reopened, sid, 11, 16); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma"); + } +} + +#[test] +fn character_capture_rejects_explicit_missing_fills_without_losing_redo() { + let (mut s, sid) = story("alpha"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap(); + let redo_doc = s.doc().unwrap().doc.clone(); + let redo_selection = s.doc().unwrap().selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] { + let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err(); + assert!(error.to_string().contains(fill)); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().selection, before.selection); + assert_eq!(s.doc().unwrap().revision, before.revision); + assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history)); + assert_eq!(s.journal, journal); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc)); + assert_eq!(s.doc().unwrap().selection, redo_selection); + assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some()); +} + +#[test] +fn character_capture_explicit_null_restores_live_paragraph_inheritance() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha\nbeta"); + select(&mut s, sid, 0, 5); + let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"}); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &source).unwrap(); + } + // Omitting chars still captures values, including a nullable attribute's numeric value. + s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0)); + assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5))); + let before = document(&s); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute( + "style.character.create", + &json!({"name": "Cleared", "fromSelection": true, + "chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}), + ) + .unwrap(); + assert_eq!( + chars(&s, "Cleared"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() } + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap(); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 30.0); + assert_eq!(effective["chars"]["underlineWeight"], 2.0); + assert_eq!(effective["chars"]["underline"], false); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 40.0); + assert_eq!(effective["chars"]["underlineWeight"], 3.0); + } +} diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/collection_state_atomicity.rs new file mode 100644 index 00000000..b73eefa7 --- /dev/null +++ b/crates/engine/tests/collection_state_atomicity.rs @@ -0,0 +1,87 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; + +fn library_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("library"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("items.dclib"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + s.execute("library.new", &json!({"path": file})).unwrap(); + s.execute("library.add", &json!({"name": "Keep"})).unwrap(); + let before = serde_json::to_value(&s.library).unwrap(); + let path = s.library.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-library"); + std::fs::rename(&folder, &kept).unwrap(); + + assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err()); + + assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items"); + assert_eq!(path, s.library.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap()); +} + +#[test] +fn failed_library_add_keeps_existing_items() { + library_failure("library.add"); +} + +#[test] +fn failed_library_remove_keeps_existing_items() { + library_failure("library.remove"); +} + +fn book_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let mut s = Session::new(); + let mut chapters = Vec::new(); + for i in 0..3 { + s.execute("file.new", &json!({})).unwrap(); + let path = temp.path().join(format!("chapter-{i}.designcraft")); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + chapters.push(path); + } + let folder = temp.path().join("book"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("chapters.dcbook"); + s.execute("book.new", &json!({"path": file})).unwrap(); + s.execute("book.add", &json!({"path": chapters[0]})).unwrap(); + s.execute("book.add", &json!({"path": chapters[1]})).unwrap(); + s.execute("book.styleSource", &json!({"index": 1})).unwrap(); + let before = serde_json::to_value(&s.book).unwrap(); + let path = s.book.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-book"); + std::fs::rename(&folder, &kept).unwrap(); + let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) }; + + assert!(s.execute(command, ¶ms).is_err()); + + assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source"); + assert_eq!(path, s.book.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap()); +} + +#[test] +fn failed_book_add_keeps_existing_chapters() { + book_failure("book.add"); +} + +#[test] +fn failed_book_remove_keeps_existing_chapters() { + book_failure("book.remove"); +} + +#[test] +fn failed_book_style_source_keeps_existing_source() { + book_failure("book.styleSource"); +} diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/distribute_locks.rs new file mode 100644 index 00000000..4935e994 --- /dev/null +++ b/crates/engine/tests/distribute_locks.rs @@ -0,0 +1,128 @@ +//! Distribution preserves explicit item locks without changing reference/spacing calculations. +use std::sync::Arc; + +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0]; +const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0]; +// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame. +const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg=="; + +fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut ids = Vec::new(); + for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() { + let cross = 40.0 + i as f64 * 35.0; + let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] }; + let graphic = i == 1 || i == 2; + let created = + s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap(); + let id = created["id"].as_u64().unwrap(); + ids.push(id); + if graphic { + s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap(); + } + } + // An unselected control must retain all geometry and content too. + s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap(); + s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap(); + let locked: Vec = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect(); + if !locked.is_empty() { + s.execute("object.lock", &json!({"ids": locked})).unwrap(); + } + // Layers selection may include locked items. Deliberately avoid geometric order. + s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap(); + assert!(!s.doc().unwrap().doc.assets.is_empty()); + s.doc().unwrap().doc.check().unwrap(); + (s, ids) +} + +fn modes() -> [(Value, [f64; 4]); 3] { + [ + // Endpoint centers 20 and 335 give a center step of 105. + (json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]), + // Outer span 350 minus total widths 140 gives three gaps of 70. + (json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]), + // An explicit spacing value selects fixed-gap mode without requiring `by`. + (json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]), + ] +} + +fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) { + let (mut s, ids) = fixture(horizontal, locks); + params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" }); + if explicit_ids { + params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let mut expected = (*before).clone(); + for (i, &id) in ids.iter().enumerate() { + if !locks[i] { + let delta = positions[i] - STARTS[i]; + let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) }; + let item = expected.item_mut(ItemId(id)).unwrap(); + item.xf = Affine::translate(translation) * item.xf; + } + } + s.execute("object.distribute", ¶ms).unwrap(); + let after = s.doc().unwrap().doc.clone(); + for (i, &id) in ids.iter().enumerate() { + let item = after.item(ItemId(id)).unwrap(); + let b = item.bounds(); + let start = if horizontal { b.x0 } else { b.y0 }; + assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}"); + if locks[i] { + assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged"); + } + } + // Only the specified translations may change: all paths, sizes, other-axis coordinates, + // paint, content, stories, layer membership, item order and embedded asset bytes are exact. + assert_eq!(*after, expected, "{params}, locks={locks:?}"); + assert_eq!(s.doc().unwrap().selection, selection); + after.check().unwrap(); + if expected != *before { + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after)); + assert_eq!(s.doc().unwrap().selection, selection); + } +} + +#[test] +fn distribute_preserves_locked_items_and_moves_unlocked_controls() { + for horizontal in [true, false] { + for (params, positions) in modes() { + // Locked interior object still participates in the numeric reference calculation. + check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false); + // Locked endpoints and an interior object; only the unlocked interior control moves. + check_distribution(horizontal, params, positions, [true, true, false, true], true); + } + } +} + +#[test] +fn distribute_unlocked_spacing_modes_are_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [false; 4], true); + } + } +} + +#[test] +fn distribute_all_locked_leaves_document_and_selection_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [true; 4], false); + } + } +} diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/preflight_resolution.rs new file mode 100644 index 00000000..f4562c56 --- /dev/null +++ b/crates/engine/tests/preflight_resolution.rs @@ -0,0 +1,217 @@ +//! Effective raster resolution follows image, frame and ancestor transforms in spread space. +use std::sync::Arc; + +use designcraft_doc::{Content, Intent, ItemId}; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId { + s.execute("selection.set", &json!({"ids": []})).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap(); + ItemId(placed["id"].as_u64().unwrap()) +} + +fn raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap(); + // 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions. + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let id = place(&mut s, "photo.png", &png); + assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn facing_raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap(); + // Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right). + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads.len(), 2); + assert_eq!(d.spreads[0].pages.len(), 1); + assert_eq!(d.spreads[1].pages.len(), 2); + assert_eq!(d.first_page_of_spread(1), 1); + assert_eq!(d.spreads[1].pages[0].x, 0.0); + assert_eq!(d.spreads[1].pages[1].x, 612.0); + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": base64_encode(&png), "name": "photo.png", "spread": 1, + "x": 0, "y": 0, "width": 144 + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1)); + assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) { + s.edit(|d, _| { + let it = d.item_mut(id).unwrap(); + it.xf = frame; + let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") }; + g.xf = image * g.xf; + Ok(()) + }) + .unwrap(); +} + +fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId { + let spread = s.doc().unwrap().doc.find(child).unwrap().spread; + let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap(); + let id = ItemId(group["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.item_mut(id).unwrap().xf = xf; + Ok(()) + }) + .unwrap(); + id +} + +fn resolutions(s: &mut Session, params: Value) -> Vec { + let before = s.doc().unwrap(); + let doc = before.doc.clone(); + let selection = before.selection.clone(); + let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len()); + let result = s.execute("preflight.run", ¶ms).unwrap(); + let after = s.doc().unwrap(); + assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched"); + assert_eq!(selection, after.selection, "preflight must leave the selection untouched"); + assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len())); + result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect() +} + +fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) { + warning_on_page(s, id, params, ppi, threshold, 0); +} + +fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) { + assert_eq!( + resolutions(s, params), + vec![json!({ + "severity": "warning", "kind": "lowResolution", "item": id.0, "page": page, + "message": format!("photo.png: effective {ppi} ppi (< {threshold})") + })] + ); +} + +#[test] +fn either_raster_axis_can_fail_after_image_or_frame_scaling() { + for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] { + for image_only in [false, true] { + let (mut s, id) = raster(); + let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) }; + transform(&mut s, id, frame, image); + // One axis remains 300 ppi, the other is 75 ppi: report the worse axis once. + warning(&mut s, id, json!({}), 75, 150); + } + } +} + +#[test] +fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() { + let (mut s, id) = raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::scale(2.0)); + warning(&mut s, id, json!({}), 75, 150); + + let (mut s, id) = raster(); + // Double image X, then turn the frame 90 degrees, then triple spread X. + // The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively. + transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0)); + group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0)); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn translated_scaled_nested_raster_reports_right_facing_page() { + let (mut s, id) = facing_raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0)); + // Local frame center x=72 is on the left page. Ancestors move its bounds to + // x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi. + warning_on_page(&mut s, id, json!({}), 75, 150, 2); +} + +#[test] +fn translating_group_changes_warning_page_without_changing_resolution() { + let (mut s, id) = facing_raster(); + // A nonidentity frame transform also detects applying the frame transform twice. + transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1); + group(&mut s, id, Affine::translate((880.0, 0.0))); + // Translation moves the center to x=712, but does not change either image axis. + assert!(resolutions(&mut s, json!({})).is_empty()); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2); +} + +#[test] +fn rotation_translation_and_reflection_do_not_change_resolution() { + for xf in [ + Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0), + ] { + let (mut s, id) = raster(); + transform(&mut s, id, xf, Affine::IDENTITY); + // Measuring the rotated bounding box instead would incorrectly warn here. + assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty()); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + } +} + +#[test] +fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() { + let (mut s, id) = raster(); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + // Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + // Proportional fill is 432 × 216 pt: 100 ppi in both directions. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn threshold_is_strict_and_resolution_remains_print_only() { + let (mut s, id) = raster(); + transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY); + assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold"); + assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty()); + warning(&mut s, id, json!({"minPpi": 151}), 150, 151); + for intent in [Intent::Web, Intent::Mobile] { + s.edit(|d, _| { + d.settings.intent = intent; + Ok(()) + }) + .unwrap(); + assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty()); + } +} + +#[test] +fn transformed_vector_previews_are_excluded_beside_rasters() { + let (mut s, id) = raster(); + let svg = br#""#; + let vector = place(&mut s, "vector.svg", svg); + let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| { + d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0); + Ok(()) + }) + .unwrap(); + warning(&mut s, id, json!({}), 75, 150); +} diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/save_state_atomicity.rs new file mode 100644 index 00000000..326d83be --- /dev/null +++ b/crates/engine/tests/save_state_atomicity.rs @@ -0,0 +1,68 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::path::Path; +use std::sync::Arc; + +use designcraft_engine::Session; +use serde_json::json; + +fn linked_document(root: &Path) -> (Session, std::path::PathBuf) { + let source = root.join("source/contacts.csv"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "Name\nAda\n").unwrap(); + let original = root.join("original/template.designcraft"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("data.source.select", &json!({"path": source})).unwrap(); + session.execute("file.saveAs", &json!({"path": original})).unwrap(); + (session, original) +} + +#[test] +fn failed_save_as_preserves_document_saved_state_history_and_original_file() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("missing/elsewhere/template.designcraft"); + + assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err()); + + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths"); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(before.doc.data_merge, after.doc.data_merge); + assert_eq!(before.history.undo.len(), after.history.undo.len()); + assert_eq!(before.history.redo.len(), after.history.redo.len()); + assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap()); + assert_eq!(journal, session.journal); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); + assert!(!destination.exists()); +} + +#[test] +fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + let before = session.doc().unwrap().clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("copies/nested/template.designcraft"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + + session.execute("file.saveACopy", &json!({"path": destination})).unwrap(); + + let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap(); + assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv")); + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc)); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); +} diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f7..c49c1d2c 100644 --- a/crates/engine/tests/text_decorations.rs +++ b/crates/engine/tests/text_decorations.rs @@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { } } } + +/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the +/// letters where the anti-aliased edges met (at fractional positions). +#[test] +fn an_underline_has_no_seams_between_letters() { + let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() }); + let chars = CharAttrs { + font_family: Some("Source Sans 3".into()), + size: Some(48.0), + fill: Some("[Black]".into()), + underline: Some(true), + underline_weight: Some(Some(6.0)), + ..Default::default() + }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() }) + .unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + // A fractional scale puts the letters' edges between pixels. + let (w, h) = (543u32, 163u32); + let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options); + let px = |x: u32, y: u32| { + let i = ((y * w + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] + }; + // The underline's middle row: the row with the longest run of dark pixels ([Black] shows as + // about 52 grey), then nothing lighter than its darkest pixel along it. + let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap(); + let dark: Vec = (0..w).filter(|&x| px(x, row)[0] < 110).collect(); + let (x0, x1) = (dark[0], *dark.last().unwrap()); + assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}"); + // The bar's own anti-aliased ends aside. + let inside = x0 + 2..=x1 - 2; + let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap(); + let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect(); + assert!(light.is_empty(), "seams in the underline at {light:?}"); +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f5934..77b509d0 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String { let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); - let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { @@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String { if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } css } @@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool { } } +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; @@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // Export Tagging: the style's element and class. - let et = doc.styles.export_tag(&pf.style, false); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); - let class = - et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); @@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { story_markup(raw) }; - let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); - let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); - let cls = if f.style != story::NO_CHAR_STYLE { - let class = et - .map(|e| e.class.clone()) - .filter(|c| !c.is_empty()) - .or_else(|| cc.get(f.style.as_str()).cloned()) - .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{}\"", xml_attr(&class)) + let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { - String::new() + ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs new file mode 100644 index 00000000..47485b53 --- /dev/null +++ b/crates/epub/tests/export_tag_styles.rs @@ -0,0 +1,138 @@ +use std::io::{Cursor, Read}; + +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::Rect; +use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story}; +use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html}; + +fn fixture() -> Document { + let mut doc = Document::new(&NewDocument::default()); + let styles = doc.styles_mut(); + styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0); + styles.paragraph.push(ParagraphStyle { + name: "Chapter Title".into(), + based_on: None, + next_style: None, + para: ParaAttrs { align: Some(Align::Center), ..Default::default() }, + chars: CharAttrs { size: Some(24.0), ..Default::default() }, + shortcut: String::new(), + }); + styles.character.push(CharacterStyle { + name: "Key Term".into(), + based_on: None, + chars: CharAttrs { size: Some(18.0), ..Default::default() }, + shortcut: String::new(), + }); + let layer = doc.default_layer(); + doc.add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + layer, + "Heading", + ParaFormat { style: "Chapter Title".into(), ..Default::default() }, + ) + .unwrap(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into()); + doc +} + +fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { + let tags = &mut doc.styles_mut().export_tags; + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); +} + +fn package(doc: &Document) -> (String, String) { + let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut text = String::new(); + zip.by_name(path).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let xhtml = read("OEBPS/content.xhtml"); + let css = read("OEBPS/style.css"); + let opf = read("OEBPS/content.opf"); + assert!(xhtml.contains("")); + assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); + (xhtml, css) +} + +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}")) +} + +#[test] +fn mapped_tags_and_classes_keep_their_packaged_style_declarations() { + let mut doc = fixture(); + map(&mut doc, "h1", "chapter", "em", "term"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

"), "{xhtml}"); + assert!(xhtml.contains("Accent plain"), "{xhtml}"); + assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;")); + assert!(rule(&css, "h1.chapter").contains("text-align: center;")); + assert!(rule(&css, "em.term").contains("font-size: 1.500em;")); + assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); + // HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS. + let html = export_html(&doc, &HtmlOptions::default()); + assert!(html.contains(&css)); + assert!(html.contains("

Heading

")); +} + +#[test] +fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() { + for (pt, pc, ct, cc, paragraph, character) in [ + ("h2", "", "em", "", "h2.chapter-title", "em.key-term"), + ("", "chapter", "", "term", "p.chapter", "span.term"), + ("section", "", "h2", "", "p.chapter-title", "span.key-term"), + ("", "", "", "", "p.chapter-title", "span.key-term"), + ] { + let mut doc = fixture(); + map(&mut doc, pt, pc, ct, cc); + let (xhtml, css) = package(&doc); + for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] { + let (tag, class) = selector.split_once('.').unwrap(); + assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}")), "{xhtml}"); + assert!(rule(&css, selector).contains(&format!("font-size: {size};"))); + } + } + let (xhtml, css) = package(&fixture()); + assert!(xhtml.contains("

Heading

")); + assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;")); + assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;")); +} + +#[test] +fn custom_class_tokens_match_without_renaming_them() { + for (class, attribute, selector) in [ + ("lead wide", "lead wide", ".lead.wide"), + ("lead\twide\n", "lead\twide\n", ".lead.wide"), + ("chapter:lead", "chapter:lead", r".chapter\3a lead"), + ("2nd-edition", "2nd-edition", r".\32 nd-edition"), + ("-2nd", "-2nd", r".-\32 nd"), + ("-", "-", r".\2d "), + ("見出し.café", "見出し.café", r".見出し\2e café"), + ("edition \"wide\"", "edition "wide"", r".edition.\22 wide\22 "), + ] { + let mut doc = fixture(); + map(&mut doc, "h2", class, "em", class); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains(&format!("

Heading

")), "{xhtml}"); + assert!(xhtml.contains(&format!("Accent")), "{xhtml}"); + assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;")); + assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;")); + } +} + +#[test] +fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() { + let mut doc = fixture(); + map(&mut doc, "p", " \t", "span", " \n"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

")); + assert!(xhtml.contains("Accent")); + assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {"))); + assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e14..bbe08af0 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -188,6 +190,32 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -435,6 +463,29 @@ const KOREAN_FALLBACKS: &[&str] = &[ "Source Han Sans K", ]; +/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters +/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs. +const MALAYALAM_FALLBACKS: &[&str] = &[ + "Malayalam Sangam MN", + "Malayalam MN", + "Nirmala UI", + "Kartika", + "Noto Sans Malayalam", + "Noto Serif Malayalam", + "Manjari", + "Rachana", + "Meera", + "Lohit Malayalam", +]; + +/// The fallback chain of a script that only fonts made for it set well, whatever the language. +fn script_chain(c: char) -> Option<&'static [&'static str]> { + match c as u32 { + 0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS), + _ => None, + } +} + /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { @@ -709,6 +760,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), }) } @@ -1277,6 +1329,43 @@ impl ScopedFonts<'_> { None } + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { @@ -1375,12 +1464,12 @@ impl ScopedFonts<'_> { } /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order, the document's fonts last) that covers - /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face - /// covers a character. + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam + /// the fonts made for it (see [`script_chain`]); then the first face (fallback family first, + /// then load order, the document's fonts last) that covers `c`. On native, system fonts are + /// cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - if let Some(chain) = cjk_chain(c, language) + if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c)) && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) { return Some(f); @@ -1432,6 +1521,53 @@ fn over_total() -> String { format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) } +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +fn has_serifs(face: &FontFace) -> Option { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + /// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). fn font_outline(face: &FontFace, gid: u32) -> BezPath { let mut pen = FlipPen(BezPath::new()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 00000000..6a224bc0 --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -0,0 +1,191 @@ +//! What the font menus say about a face: its file format (the icon after the sample), its +//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts). + +use skrifa::raw::TableProvider; +use skrifa::raw::types::Tag; + +/// A face's font format, as the font menus show it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontFormat { + /// TrueType outlines (`glyf`). + TrueType, + /// PostScript (CFF) outlines. + OpenType, + /// A variable font (`fvar`). + Variable, + /// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts. + Color, +} + +/// The classes the font menus filter by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontClass { + Serif, + SlabSerif, + SansSerif, + Script, + Monospaced, + Decorative, + Symbol, + /// Nothing in the font says. + Other, +} + +impl FontClass { + /// The classes, in the Filters menu's order. + pub const ALL: [FontClass; 8] = [ + FontClass::Serif, + FontClass::SlabSerif, + FontClass::SansSerif, + FontClass::Script, + FontClass::Monospaced, + FontClass::Decorative, + FontClass::Symbol, + FontClass::Other, + ]; + + /// The name the Filters menu shows (English; the UI translates it). + pub fn label(self) -> &'static str { + match self { + FontClass::Serif => "Serif", + FontClass::SlabSerif => "Slab Serif", + FontClass::SansSerif => "Sans Serif", + FontClass::Script => "Script", + FontClass::Monospaced => "Monospaced", + FontClass::Decorative => "Decorative", + FontClass::Symbol => "Symbol", + FontClass::Other => "Other", + } + } +} + +/// A face's format, class and PANOSE numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FaceKind { + pub format: FontFormat, + pub class: FontClass, + /// The OS/2 table's PANOSE classification (all zero when the font has none). + pub panose: [u8; 10], +} + +impl Default for FaceKind { + fn default() -> Self { + FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] } + } +} + +/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say. +pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind { + let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some(); + let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) { + FontFormat::Color + } else if has(b"fvar") { + FontFormat::Variable + } else if has(b"CFF ") || has(b"CFF2") { + FontFormat::OpenType + } else { + FontFormat::TrueType + }; + let os2 = f.os2().ok(); + let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]); + let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8); + let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0); + FaceKind { format, class: classify(&panose, ibm, fixed, family), panose } +} + +/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2, +/// else the family name. +pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass { + let name = family.to_lowercase(); + let named = |words: &[&str]| words.iter().any(|w| name.contains(w)); + // PANOSE proportion 9 is monospaced (Latin text). + if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) { + return FontClass::Monospaced; + } + match panose[0] { + 3 => return FontClass::Script, + 4 => return FontClass::Decorative, + 5 => return FontClass::Symbol, + 2 => match panose[1] { + // Square serifs are slabs; cove, bone, triangle… serifs are serifs. + 6 => return FontClass::SlabSerif, + 2..=10 => return FontClass::Serif, + 11..=15 => return FontClass::SansSerif, + _ => {} + }, + _ => {} + } + match ibm_class { + 1 | 2 | 3 | 7 => FontClass::Serif, + 4 | 5 => FontClass::SlabSerif, + 8 => FontClass::SansSerif, + 9 => FontClass::Decorative, + 10 => FontClass::Script, + 12 => FontClass::Symbol, + _ if named(&["slab"]) => FontClass::SlabSerif, + _ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif, + _ if named(&["script", "hand", "brush"]) => FontClass::Script, + _ if named(&["serif"]) => FontClass::Serif, + _ => FontClass::Other, + } +} + +/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both +/// fonts set; `None` when they share none. +pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option { + let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::() * 9 / pairs.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_come_from_panose_then_the_ibm_class_then_the_name() { + let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0]; + assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif); + assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif); + assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif); + assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9"); + assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script); + assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8"); + assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch"); + assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced); + assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif); + assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other); + } + + #[test] + fn bundled_fonts_are_classified() { + let db = crate::FontDb::global(); + for (family, class) in [ + ("Source Serif 4", FontClass::Serif), + ("Source Sans 3", FontClass::SansSerif), + ("Inter", FontClass::SansSerif), + ("JetBrains Mono", FontClass::Monospaced), + ] { + let kind = db.face(family, "Regular").kind(); + assert_eq!(kind.class, class, "{family}"); + assert_eq!(kind.format, FontFormat::TrueType, "{family}"); + } + let serif = db.face("Source Serif 4", "Regular").kind().panose; + let sans = db.face("Source Sans 3", "Regular").kind().panose; + let serif_bold = db.face("Source Serif 4", "Bold").kind().panose; + if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) { + assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})"); + } + } + + #[test] + fn panose_distance_counts_shared_digits() { + let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4]; + assert_eq!(panose_distance(&a, &a), Some(0)); + let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4]; + assert!(panose_distance(&a, &b).is_some_and(|d| d > 0)); + assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d74478..dd458370 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; mod group; +mod kind; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -20,6 +21,7 @@ pub use fontdb::{ pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; +pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs index 4d81f9a5..d9e27ac6 100644 --- a/crates/fonts/src/tests_fallback.rs +++ b/crates/fonts/src/tests_fallback.rs @@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() { assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); assert!(!db.is_loaded("AppleMyungjo")); } + +const KA: char = 'ക'; +const VIRAMA: char = '\u{0D4D}'; +const AA: char = '\u{0D3E}'; + +#[test] +fn malayalam_takes_a_font_made_for_it() { + // Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won. + let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]); + for c in [KA, VIRAMA, AA] { + for language in [None, Some("en-US"), Some("ml")] { + assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}"); + } + } + // Past the Malayalam fonts, the language-blind search as before. + let sangam = db.face("Malayalam Sangam MN", "Regular"); + assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS")); +} + +#[test] +fn malayalam_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap()); + assert!(!db.is_loaded("Noto Sans Malayalam")); + assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam")); +} diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cd..ca82ad4d 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { assert_eq!(db.load_system_fonts(), 2); } +#[test] +fn a_familys_kind_is_read_without_keeping_it_loaded() { + let dir = font_dir("kind"); + let db = FontDb::with_font_dirs(vec![dir]); + db.load_system_fonts(); + let fonts = db.scoped(0); + let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family"); + assert_eq!(kind.class, crate::kind::FontClass::SansSerif); + assert!(added.is_some(), "the file's date"); + assert!(!db.is_loaded(FAMILY), "the file was read and let go"); + // A loaded family is asked directly, with the same answer. + let _ = fonts.face(FAMILY, "Regular"); + assert!(db.is_loaded(FAMILY)); + assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif)); + assert!(fonts.family_kind("No Such Family").is_none()); +} + #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a6..408bb3cc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -2,14 +2,15 @@ //! //! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip` //! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`) -//! - `assets/.`: embedded placed files, byte-for-byte +//! - `assets/.`: embedded placed files, byte-for-byte: one per distinct file, and only +//! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator //! //! Older single-file JSON documents (assets as base64 in `assetData`) still open. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -42,8 +43,12 @@ fn ext_for(mime: &str) -> &'static str { } } -/// Serialize a document to `.designcraft` bytes. +/// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once +/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back). pub fn save(doc: &Document) -> Result, FormatError> { + let mut compact = doc.clone(); + compact.compact_assets(); + let doc = &compact; let io = |e: zip::result::ZipError| FormatError::Io(e.to_string()); let mut buf = Cursor::new(Vec::new()); { @@ -100,13 +105,26 @@ fn load_zip(bytes: &[u8]) -> Result { upgrade(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); + // Files saved with one copy per placement: identical copies share one buffer as they are + // read, so memory holds each distinct file once. + let mut loaded: HashMap>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { - Arc::make_mut(a).data = Arc::new(data); + let same = loaded.entry(data.len()).or_default(); + let data = match same.iter().find(|d| ***d == data) { + Some(d) => d.clone(), + None => { + let d = Arc::new(data); + same.push(d.clone()); + d + } + }; + Arc::make_mut(a).data = data; } } + doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -124,6 +142,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { } } } + doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -205,24 +224,67 @@ mod tests { use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; + fn png_asset(d: &mut Document, data: &[u8]) -> AssetId { + let aid = AssetId(d.alloc()); + let a = Asset { + page: 0, + id: aid, + name: "x.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(data.to_vec()), + pixels: Some((1, 1)), + }; + d.assets.insert(aid, Arc::new(a)); + aid + } + + fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId { + use designcraft_doc::geom::{Affine, shapes}; + use designcraft_doc::{Content, Graphic, Item, ItemId, Shape}; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); + it.content = Content::Graphic(Graphic { + asset, + size: (1.0, 1.0), + xf: Affine::translate((crop, crop)), + auto_fit: Default::default(), + fit_align: 4, + crop: [crop; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id + } + + fn asset_entries(bytes: &[u8]) -> usize { + let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + z.file_names().filter(|n| n.starts_with("assets/")).count() + } + + /// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned). + fn save_uncompacted(d: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + z.start_file("mimetype", opts).unwrap(); + z.write_all(MIME.as_bytes()).unwrap(); + z.start_file("document.json", opts).unwrap(); + z.write_all(&serde_json::to_vec(d).unwrap()).unwrap(); + for (id, a) in &d.assets { + z.start_file(format!("assets/{}.png", id.0), opts).unwrap(); + z.write_all(&a.data).unwrap(); + } + z.finish().unwrap(); + buf.into_inner() + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: "x.png".into(), - mime: "image/png".into(), - link: None, - data: Arc::new(vec![1, 2, 3, 4]), - pixels: Some((1, 1)), - }), - ); + let aid = png_asset(&mut d, &[1, 2, 3, 4]); + place(&mut d, aid, 0.0); let bytes = save(&d).unwrap(); assert!(bytes.starts_with(b"PK")); let back = load(&bytes).unwrap(); @@ -231,6 +293,63 @@ mod tests { assert_eq!(back.stories, d.stories); } + #[test] + fn saves_hold_each_used_file_once() { + let mut d = Document::new(&NewDocument::default()); + let first = png_asset(&mut d, &[7; 64]); + let copy = png_asset(&mut d, &[7; 64]); + let replaced = png_asset(&mut d, &[9; 64]); + let a = place(&mut d, first, 1.0); + let b = place(&mut d, copy, 2.0); + assert_eq!(d.assets.len(), 3); + + // Duplicates merge into the first asset, the unreferenced one is left out, and every + // placement keeps its own crop and transform. + let bytes = save(&d).unwrap(); + assert_eq!(asset_entries(&bytes), 1); + assert_eq!(d.assets.len(), 3, "the open document keeps its assets"); + let back = load(&bytes).unwrap(); + assert_eq!(back.assets.keys().copied().collect::>(), vec![first]); + assert!(!back.assets.contains_key(&replaced)); + for (id, crop) in [(a, 1.0), (b, 2.0)] { + let g = back.item(id).and_then(|i| i.graphic()).unwrap(); + assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf)); + } + } + + #[test] + fn files_with_identical_bytes_at_different_paths_keep_their_paths() { + let mut d = Document::new(&NewDocument::default()); + let ids: Vec = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect(); + for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) { + if let Some(a) = d.assets.get_mut(id) { + Arc::make_mut(a).link = Some(link.into()); + } + } + let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect(); + for bytes in [save(&d).unwrap(), save_uncompacted(&d)] { + let back = load(&bytes).unwrap(); + assert_eq!(back.assets.len(), 3); + let links: Vec> = items + .iter() + .map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref()) + .collect(); + assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]); + } + } + + #[test] + fn older_files_with_one_asset_per_placement_open_with_one() { + let mut d = Document::new(&NewDocument::default()); + let ids: Vec = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect(); + for (k, id) in ids.iter().enumerate() { + place(&mut d, *id, k as f64); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 1); + assert_eq!(asset_entries(&save(&back).unwrap()), 1); + } + /// The object of the item `id` in a document's JSON. fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> { if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() { diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa..6b0c9cb0 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -1,9 +1,10 @@ //! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy. //! -//! Corners are applied at render time to the straight-segment corners of a closed path, so the -//! frame keeps its simple editable geometry (as in InDesign). +//! Corners are applied at render time to the corner points of a closed path (where its direction +//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay +//! curved: a corner where an arc meets a line trims the arc along its length. -use kurbo::{BezPath, Point, Vec2}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2}; use serde::{Deserialize, Serialize}; use crate::path::{PathData, SubPath}; @@ -63,8 +64,9 @@ impl CornerOptions { } } -/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of -/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. +/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths +/// (where the incoming and outgoing directions differ, curved sides included); smooth points are +/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { if opts.is_none() { return path.to_bezpath(); @@ -76,50 +78,84 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { out } +/// Arc length accuracy (points) when trimming curved sides. +const ARC_ACCURACY: f64 = 1e-3; +/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner. +const SMOOTH: f64 = 0.9998; + +/// The unit direction a segment leaves its start in (None for a point). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The unit direction a segment arrives at its end in (None for a point). +fn end_dir(c: &CubicBez) -> Option { + [c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Does the path turn where `prev` ends and `next` starts? +fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool { + matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH) +} + fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let n = sp.anchors.len(); - let straight_corner = |i: usize| { - let a = &sp.anchors[i]; - sp.closed && !a.has_in() && !a.has_out() - }; - if !sp.closed || n < 3 || !(0..n).any(straight_corner) { + if !sp.closed || n < 2 { sp.to_bezpath_into(out); return; } - let pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); - let mut first = true; - for i in 0..n { - let c = opts.corners[i % 4]; - let p = pts[i]; - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() { - if first { - out.move_to(p); - first = false; - } else { - out.line_to(p); + // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments + // `i - 1` and `i`, and takes at most half of each. + let segs: Vec = (0..n).map(|i| sp.segment(i)).collect(); + let lines: Vec = (0..n).map(|i| sp.segment_is_line(i)).collect(); + // Straight sides are measured (and trimmed below) exactly; curves by arc length. + let lens: Vec = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect(); + let size: Vec = (0..n) + .map(|i| { + let c = opts.corners[i % 4]; + let prev = (i + n - 1) % n; + if c.is_none() || !is_corner(&segs[prev], &segs[i]) { + return 0.0; } - continue; - } - let din = prev - p; - let dout = next - p; - let (lin, lout) = (din.hypot(), dout.hypot()); - if lin < 1e-9 || lout < 1e-9 { - continue; - } - let s = c.size.min(lin / 2.0).min(lout / 2.0); - let ui = din / lin; - let uo = dout / lout; - let a = p + ui * s; // entry point on incoming edge - let b = p + uo * s; // exit point on outgoing edge - if first { - out.move_to(a); - first = false; + c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0) + }) + .collect(); + if size.iter().all(|s| *s <= 0.0) { + sp.to_bezpath_into(out); + return; + } + // Each segment without the parts its corners take. + let trimmed: Vec = (0..n) + .map(|i| { + let (s0, s1) = (size[i], size[(i + 1) % n]); + if lines[i] { + let c = &segs[i]; + let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO }; + let (a, b) = (c.p0 + u * s0, c.p3 - u * s1); + return CubicBez::new(a, a, b, b); + } + let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; + let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; + segs[i].subsegment(t0..t1.max(t0)) + }) + .collect(); + out.move_to(trimmed[0].p0); + for i in 0..n { + let seg = &trimmed[i]; + if lines[i] { + out.line_to(seg.p3); } else { - out.line_to(a); + out.curve_to(seg.p1, seg.p2, seg.p3); + } + // The corner at the segment's end, from where this segment stops to where the next starts. + let j = (i + 1) % n; + let next = &trimmed[j]; + if size[j] > 0.0 { + match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) { + (Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]), + _ => out.line_to(next.p0), + } } - corner_shape(out, c.shape, p, a, b, ui, uo, s); } out.close_path(); } @@ -185,6 +221,67 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + fn nearest(bp: &BezPath, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100). + fn quarter_disc() -> PathData { + let k = 100.0 * crate::shapes::KAPPA; + let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0)); + a0.h_out = Point::new(k, 0.0); + let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0)); + a1.h_in = Point::new(100.0, 100.0 - k); + let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0)); + PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }]) + } + + #[test] + fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() { + let p = quarter_disc(); + let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0)); + // Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a + // triangle would be 5000. + let area = bp.area().abs(); + assert!(area > 7300.0 && area < 7854.0, "area {area}"); + // The middle of the arc is kept. + let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2); + assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid)); + // All three corners are rounded off: the line-line one and both where the arc meets a line. + for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp"); + } + } + + #[test] + fn straight_sides_are_trimmed_exactly() { + let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0)); + // Every point a bevelled rectangle goes through sits exactly 10 pt from a corner. + let pts: Vec = bp + .elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(pts.len(), 9, "{pts:?}"); + for p in pts { + assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}"); + } + } + + #[test] + fn smooth_points_are_not_corners() { + let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + for shape in CornerShape::ALL.into_iter().skip(1) { + let bp = apply(&e, &CornerOptions::uniform(shape, 20.0)); + assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}"); + } + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b8907..34041d28 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -47,6 +47,22 @@ impl PathWarp { None } + /// The arc length (as `at` measures it, so flipped paths count from their end) of the point + /// of the path nearest `p`. + pub fn nearest(&self, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + let mut before = 0.0; + for (seg, len) in &self.segs { + let n = seg.nearest(p, 1e-6); + if n.distance_sq < best.0 { + best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4)); + } + before += len; + } + if self.flip { self.total - best.1 } else { best.1 } + } + /// Transform for something laid out on a straight line (x along the line, y down from the /// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with /// the path, its line at `baseline` on the path. @@ -76,5 +92,10 @@ mod tests { assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9); let flipped = PathWarp::new(&bp, true); assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9); + // The nearest point by arc length, off the path and on it; flipped counts from the end. + assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6); + assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end"); + assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6); + assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b..d01abe9e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -33,10 +33,17 @@ impl Default for ExportOptions { } /// Export a document as an IDML package (ZIP bytes). +/// +/// IDML has one sort key per index topic path. If native references disagree, the first +/// reference serialized for that path supplies its key, including an empty/default key. +/// Later references cannot overwrite it; no synthetic topic names or sort keys are created. +/// Consistent references retain their keys. Conflicting per-reference keys cannot all survive +/// this topic-level representation. This policy also applies to [`export_idml_with`]. pub fn export_idml(doc: &Document) -> Vec { export_idml_with(doc, &ExportOptions::default()) } +/// Export with options, using the index sort-key conflict policy of [`export_idml`]. pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec { let mut ex = Ex::new(doc, opts); let parts = ex.build(); @@ -133,8 +140,8 @@ struct Ex<'a> { threads: HashMap, Option)>, /// Cross-reference sources written so far: (Self, target anchor). xref_sources: Vec<(String, u64)>, - /// Index topics (paths) and their See / See also cross-references. - index_topics: BTreeSet>, + /// Index topic path → first serialized sort key; See / See also cross-references. + index_topics: BTreeMap, String>, index_xrefs: Vec<(Vec, &'static str, String)>, } @@ -148,7 +155,7 @@ impl<'a> Ex<'a> { fonts: BTreeMap::new(), threads: HashMap::new(), xref_sources: Vec::new(), - index_topics: BTreeSet::new(), + index_topics: BTreeMap::new(), index_xrefs: Vec::new(), }; ex.assign_swatch_ids(); @@ -1629,7 +1636,7 @@ impl<'a> Ex<'a> { .attr("TextAlignment", names::path_align_out(pt.align)) .attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" }) .attr("StartBracket", num(pt.start)) - .attr("EndBracket", num(len)), + .attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))), ); } else if let Content::Text(t) = &it.content { let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None)); @@ -1809,12 +1816,12 @@ impl<'a> Ex<'a> { fn index_el(&mut self) -> El { let mut topics = self.index_topics.clone(); for (_, _, target) in &self.index_xrefs { - topics.insert(vec![target.clone()]); + topics.entry(vec![target.clone()]).or_default(); } - fn build(ex: &mut Ex, topics: &BTreeSet>, prefix: &[String]) -> Vec { + fn build(ex: &mut Ex, topics: &BTreeMap, String>, prefix: &[String]) -> Vec { let mut out = Vec::new(); - for t in topics.iter().filter(|t| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { - let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", "").attr("Name", t.last().map_or("", String::as_str)); + for (t, sort) in topics.iter().filter(|(t, _)| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { + let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", sort).attr("Name", t.last().map_or("", String::as_str)); for (from, kind, target) in ex.index_xrefs.clone() { if from == *t { el.push( @@ -2069,7 +2076,9 @@ impl<'a> Ex<'a> { if let Some(r) = s.index_refs.get(k).filter(|r| !r.topics.is_empty()) { use designcraft_doc::index::IndexRange as R; for n in 1..=r.topics.len() { - self.index_topics.insert(r.topics[..n].to_vec()); + // A shared topic has one key: keep the first reference's + // value (even empty), rather than inventing a merged key. + self.index_topics.entry(r.topics[..n].to_vec()).or_insert_with(|| r.sort.get(n - 1).cloned().unwrap_or_default()); } let (kind, limit) = match &r.range { R::CurrentPage => (Some("CurrentPage"), None), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee0..59ca44be 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -48,8 +48,24 @@ fn default_reader(path: &str) -> Option> { /// Import with a custom reader for linked files (`path` → bytes). pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { + import_idml_report(bytes, read_link).map(|r| r.document) +} + +/// An imported IDML document and what the import could not bring in. +#[derive(Debug)] +pub struct Imported { + pub document: Document, + /// One line per problem that did not stop the import (a part listed in `designmap.xml` but + /// absent from the package, …). + pub warnings: Vec, +} + +/// Import with a custom reader for linked files, reporting the problems that did not stop it. +pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; let mut files: HashMap> = HashMap::new(); + // Normalised part name → entry name, first entry wins (zip order, so the choice is stable). + let mut lenient: HashMap = HashMap::new(); for i in 0..zip.len() { let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; if f.is_dir() { @@ -59,35 +75,81 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option // The size is what the archive claims: don't reserve more than a sane part up front. let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); files.insert(name, buf); } - if let Some(m) = files.get("mimetype") + let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n))); + if let Some(m) = part("mimetype") && String::from_utf8_lossy(m).trim() != MIMETYPE { return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim()))); } - let dm = files.get("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; + let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; if root.local() != "Document" { return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name))); } // Flatten includes: every `idPkg:*` child is replaced by the children of its part's root. let mut top: Vec = Vec::new(); + let mut warnings = Vec::new(); for c in root.elements() { if c.name.starts_with("idPkg:") { let Some(src) = c.get("src") else { continue }; - let Some(data) = files.get(src) else { continue }; - let part = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?; - top.extend(part.elements().cloned()); + let Some(data) = part(src) else { + warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported")); + continue; + }; + let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?; + top.extend(el.elements().cloned()); } else { top.push(c.clone()); } } let mut im = Importer::new(read_link); im.run(&root, &top)?; - let d = im.finish(&root)?; - d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; - Ok(d) + let document = im.finish(&root).map_err(|e| match e { + // Without its spread parts there is nothing to open: say which parts were missing. + IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))), + e => e, + })?; + document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; + Ok(Imported { document, warnings }) +} + +/// A package part name in the form the lookup compares: InDesign and other IDML writers differ +/// in percent-encoding, case, path separators and a leading `./` or `/`. +fn normalize_part_name(name: &str) -> String { + let decoded = percent_decode(&name.replace('\\', "/")); + let mut rest = decoded.as_str(); + loop { + if let Some(r) = rest.strip_prefix("./") { + rest = r; + } else if let Some(r) = rest.strip_prefix('/') { + rest = r; + } else { + break; + } + } + rest.to_lowercase() +} + +/// Decode `%XX` escapes; anything else (including a malformed escape) is kept as written. +pub(crate) fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c == b'%' + && let (Some(h), Some(l)) = (b.get(i + 1).and_then(|h| (*h as char).to_digit(16)), b.get(i + 2).and_then(|l| (*l as char).to_digit(16))) + { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + out.push(c); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() } struct ItemCtx { @@ -132,6 +194,8 @@ struct Importer<'r> { page_index: HashMap, item_ids: HashMap, assets: BTreeMap>, + /// Assets read from a link path, so later placements of that path reuse them unread. + linked_assets: HashMap, ctx: ItemCtx, sections: Vec
, footnote_options: designcraft_doc::FootnoteOptions, @@ -142,11 +206,32 @@ struct Importer<'r> { /// Cross-reference format Self → name. xref_format_names: HashMap, xref_formats: Vec, - /// Index topic Self → topic path; topic See / See also cross-references. - index_topics: HashMap>, + /// Index topic Self → topic path and per-level sort keys; topic cross-references. + index_topics: HashMap, index_xrefs: Vec, } +/// IDML stores a sort key on each topic, rather than on its page references. +#[derive(Clone, Default)] +struct IndexTopic { + path: Vec, + sort: Vec, +} + +impl IndexTopic { + fn child(&self, topic: &El) -> Self { + let mut path = self.path.clone(); + path.push(topic.get("Name").unwrap_or("").to_string()); + let mut sort = self.sort.clone(); + if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) { + // Keep empty positions before a child's key, but omit trailing defaults. + sort.resize(self.path.len(), String::new()); + sort.push(key.to_string()); + } + Self { path, sort } + } +} + fn lab_to_color(l: f32, a: f32, b: f32) -> Color { let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b))); Color::rgb(rgb[0], rgb[1], rgb[2]) @@ -217,6 +302,7 @@ impl<'r> Importer<'r> { page_index: HashMap::new(), item_ids: HashMap::new(), assets: BTreeMap::new(), + linked_assets: HashMap::new(), ctx: ItemCtx { threads: Vec::new() }, sections: Vec::new(), footnote_options: Default::default(), @@ -388,7 +474,7 @@ impl<'r> Importer<'r> { self.xref_format(e); } if let Some(ix) = top.iter().find(|e| e.local() == "Index") { - self.index_topics_of(ix, &[]); + self.index_topics_of(ix, &IndexTopic::default()); } // Stories (ids first so frames can reference them). for e in top.iter().filter(|e| e.local() == "Story") { @@ -945,33 +1031,31 @@ impl<'r> Importer<'r> { } /// Index topics (recursively); at the root, their See / See also cross-references too. - fn index_topics_of(&mut self, e: &El, prefix: &[String]) { + fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) { for t in e.find_all("Topic") { - let mut path = prefix.to_vec(); - path.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); if let Some(me) = t.get("Self") { - self.index_topics.insert(me.to_string(), path.clone()); + self.index_topics.insert(me.to_string(), topic.clone()); } - self.index_topics_of(t, &path); + self.index_topics_of(t, &topic); } - if !prefix.is_empty() { + if !prefix.path.is_empty() { return; } - let mut stack = vec![(e, Vec::::new())]; - while let Some((el, path)) = stack.pop() { + let mut stack = vec![(e, IndexTopic::default())]; + while let Some((el, prefix)) = stack.pop() { for t in el.find_all("Topic") { - let mut p = path.clone(); - p.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); for x in t.find_all("CrossReference") { - let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.join(": ")).unwrap_or_default(); + let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default(); use designcraft_doc::index::IndexRange as R; let range = match x.get("CrossReferenceType") { Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target), _ => R::See(target), }; - self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range }); + self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range }); } - stack.push((t, p)); + stack.push((t, topic)); } } } @@ -1731,14 +1815,14 @@ impl<'r> Importer<'r> { } "PageReference" => { use designcraft_doc::index::IndexRange as R; - let topics = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); + let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); let range = match c.get("PageReferenceType") { Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory, Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32), Some("SuppressPageNumbers") => R::SuppressPageRange, _ => R::CurrentPage, }; - b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range }); + b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range }); b.push(&designcraft_doc::INDEX_MARK.to_string(), cf); } "CrossReferenceSource" => { @@ -2116,6 +2200,11 @@ impl<'r> Importer<'r> { let options = TextFrameOptions { path: Some(designcraft_doc::PathType { start: tp.num("StartBracket").unwrap_or(0.0).max(0.0), + // At (or past) the path's length it is the path's end: no end bracket. + end: tp + .num("EndBracket") + .filter(|e| e.is_finite() && *e >= 0.0) + .filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01), flip: tp.get("FlipPathEffect") == Some("Flipped"), align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")), }), @@ -2205,12 +2294,29 @@ impl<'r> Importer<'r> { let size = ((r - l).abs(), (b - t).abs()); let link = g.find("Link"); let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); + let graphic = |asset| { + Content::Graphic(designcraft_doc::Graphic { + asset, + size, + xf: gxf * Affine::translate((l, t)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }) + }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); + let mut read_from = None; if data.is_empty() && let Some(p) = &uri - && let Some(d) = (self.read_link)(p) { - data = d; + // Every placement of a linked file shares one asset; read the file once. + if let Some(id) = self.linked_assets.get(p) { + return graphic(*id); + } + if let Some(d) = (self.read_link)(p) { + data = d; + read_from = Some(p.clone()); + } } let (mime, px) = sniff_image(&data); let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { @@ -2226,17 +2332,14 @@ impl<'r> Importer<'r> { }); let name = uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); - let id = AssetId(self.alloc()); + let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); - self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels })); - Content::Graphic(designcraft_doc::Graphic { - asset: id, - size, - xf: gxf * Affine::translate((l, t)), - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - }) + let id = + designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); + if let Some(p) = read_from { + self.linked_assets.insert(p, id); + } + graphic(id) } fn link_threads(&mut self) { @@ -2543,21 +2646,9 @@ fn path_of(pg: &El) -> PathData { pub(crate) fn uri_to_path(uri: &str) -> String { let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri); // `file:///C:/…` → `/C:/…` → `C:/…` - let rest = if rest.len() > 3 && rest.starts_with('/') && rest.as_bytes()[2] == b':' { &rest[1..] } else { rest }; - let mut out = Vec::with_capacity(rest.len()); - let b = rest.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && i + 2 < b.len() - && let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16) - { - out.push(v); - i += 3; - continue; - } - out.push(b[i]); - i += 1; - } - String::from_utf8_lossy(&out).to_string() + let rest = match rest.as_bytes() { + [b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest), + _ => rest, + }; + percent_decode(rest) } diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 71d68825..0526070d 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -49,7 +49,7 @@ mod xml; mod tests; pub use export::{ExportOptions, export_idml, export_idml_with}; -pub use import::{import_idml, import_idml_with}; +pub use import::{Imported, import_idml, import_idml_report, import_idml_with}; /// The IDML package mimetype (content of the first, stored `mimetype` entry). pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package"; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587..a2ee8f2d 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -7,7 +7,9 @@ use designcraft_geom::{Rect, shapes}; use super::*; +mod assets; mod decorations; +mod index_sort; fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; @@ -427,6 +429,60 @@ fn imports_scalar_insets_in_object_style_without_replacing_frame_override() { assert_eq!(frame_inset(&d), [3.0; 4]); } +/// A one-page spread part `Self` with one rectangle. +fn one_frame_spread(id: &str) -> String { + format!( + r#" + + + + + + + + + + "# + ) +} + +/// Writers differ in how a designmap `src` spells its part: percent-encoded, another case, +/// backslashes, a leading `./`. Those resolve; a part absent from the package is a warning that +/// names it, not silently dropped objects. +#[test] +fn designmap_parts_match_leniently_and_missing_ones_are_reported() { + let designmap = r#" + + + + "#; + let zip = zip_files(&[ + ("designmap.xml", designmap), + ("Spreads/Spread_u1a b.xml", &one_frame_spread("u1")), + ("Spreads/Spread_u2.xml", &one_frame_spread("u2")), + ]); + let r = import_idml_report(&zip, &|_| None).unwrap(); + assert_eq!(r.document.spreads.len(), 2, "both lenient spellings resolve"); + assert!(r.document.spreads.iter().all(|sp| sp.items.len() == 1), "the spreads keep their frames"); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert!(r.warnings[0].contains("Spreads/Spread_gone.xml"), "{:?}", r.warnings); + + // With every spread part missing the import fails, and the error names the part. + let only_missing = r#" + + "#; + let err = import_idml(&zip_files(&[("designmap.xml", only_missing)])).unwrap_err(); + assert!(err.to_string().contains("Spreads/Spread_gone.xml"), "{err}"); +} + +#[test] +fn percent_decode_keeps_malformed_escapes() { + assert_eq!(import::percent_decode("a%20b%2"), "a b%2"); + assert_eq!(import::percent_decode("%é%zz"), "%é%zz"); + assert_eq!(import::uri_to_path("file:///C:/a%20b"), "C:/a b"); + assert_eq!(import::uri_to_path("file:/a%bé"), "/a%bé"); +} + #[test] fn rejects_non_idml() { assert!(import_idml(b"not a zip").is_err()); @@ -1399,3 +1455,27 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn type_on_a_path_keeps_its_start_and_end_brackets() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + for end in [Some(250.0), None] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false)); + let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default()); + story.frames = vec![id]; + d.stories.insert(sid, std::sync::Arc::new(story)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap(); + assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}"); + assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); + } +} diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs new file mode 100644 index 00000000..dd276144 --- /dev/null +++ b/crates/idml/src/tests/assets.rs @@ -0,0 +1,110 @@ +//! Placed files: every placement of one file shares an asset. + +use std::cell::Cell; + +use designcraft_doc::{AssetId, Content}; + +use super::*; + +const PHOTO: &str = "file:/photos/wedding%20day/couple.jpg"; + +/// A graphic frame at `x` showing `image` (an `` element) with `crop` on every side. +fn frame(n: usize, x: f64, crop: f64, image: &str) -> String { + format!( + r#" + + + + + {image} + "# + ) +} + +fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String { + let contents = contents.map(|c| format!("", base64_encode(c))).unwrap_or_default(); + format!( + r#" + {contents} + + "# + ) +} + +fn package(frames: &[String]) -> Vec { + let spread = format!( + r#" + + + + {} + +"#, + frames.join("\n") + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// (asset, crop, transform) of each graphic, left to right. +fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> { + let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => Some((i.xf.translation().x, g.asset, g.crop, g.xf)), + _ => None, + }) + .collect(); + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.into_iter().map(|(_, a, c, x)| (a, c, x)).collect() +} + +#[test] +fn one_file_placed_several_times_imports_as_one_asset() { + let bytes = vec![42u8; 300]; + let reads = Cell::new(0); + let reader = |p: &str| { + reads.set(reads.get() + 1); + p.ends_with("couple.jpg").then(|| bytes.clone()) + }; + // Three placements linking the file, and one embedding the same bytes under another path: + // that one keeps its own asset and path. + let frames = vec![ + frame(1, 0.0, 1.0, &image(1, PHOTO, None)), + frame(2, 100.0, 2.0, &image(2, PHOTO, None)), + frame(3, 200.0, 3.0, &image(3, PHOTO, None)), + frame(4, 300.0, 4.0, &image(4, "file:/elsewhere/copy.jpg", Some(&bytes))), + ]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + assert_eq!(reads.get(), 1, "the linked file is read once"); + assert_eq!(d.assets.len(), 2); + let p = placements(&d); + assert_eq!(p.iter().map(|(_, c, _)| c[0]).collect::>(), vec![1.0, 2.0, 3.0, 4.0]); + let link = |id| d.assets.get(id).and_then(|a| a.link.as_deref()); + let links: Vec<_> = p.iter().map(|(id, _, _)| link(id)).collect(); + assert_eq!(links[..3], [Some("/photos/wedding day/couple.jpg"); 3]); + assert_eq!(links[3], Some("/elsewhere/copy.jpg")); + assert!(p[..3].iter().all(|(id, _, _)| *id == p[0].0)); + + // Exported linked or embedded, the round trip keeps the two assets and each placement's crop + // and transform. + for embed_images in [false, true] { + let out = export_idml_with(&d, &ExportOptions { embed_images }); + let back = import_idml_with(&out, &reader).unwrap(); + assert_eq!(back.assets.len(), 2, "embed_images: {embed_images}"); + let q = placements(&back); + assert_eq!(q.len(), 4); + for ((_, c0, x0), (_, c1, x1)) in p.iter().zip(&q) { + assert_eq!(c0, c1); + assert!(x0.as_coeffs().iter().zip(x1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{x0:?} {x1:?}"); + } + } +} diff --git a/crates/idml/src/tests/index_sort.rs b/crates/idml/src/tests/index_sort.rs new file mode 100644 index 00000000..e35e7fed --- /dev/null +++ b/crates/idml/src/tests/index_sort.rs @@ -0,0 +1,240 @@ +//! Original synthetic fixtures: no Adobe-generated files or implementation details. +//! Behavioral reference: https://developer.adobe.com/indesign/uxp/dom/api/t/topic/ + +use std::io::Read; + +use designcraft_doc::IndexRef; +use designcraft_doc::index::{self, IndexRange, IndexSpec, Resolved}; + +use super::*; +use crate::xml::El; + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|v| (*v).to_string()).collect() +} + +fn reference(topics: &[&str], sort: &[&str], range: IndexRange) -> IndexRef { + IndexRef { topics: strings(topics), sort: strings(sort), range } +} + +fn references(doc: &Document) -> Vec { + doc.stories.values().flat_map(|s| s.index_refs.iter().map(|r| r.as_ref().clone())).collect() +} + +fn native(refs: Vec) -> Document { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), layer, "Source", ParaFormat::default()).unwrap(); + let story = doc.story_mut(sid).unwrap(); + for r in refs { + story.insert_index_ref(story.len(), r); + } + doc.check().unwrap(); + doc +} + +fn import_xml(index: &str, refs: &str) -> Document { + let map = format!( + r#" + {index} + + + + "# + ); + let story = format!( + r#" + + Source{refs} + + "# + ); + let spread = r#" + + + + + + + + + "#; + import_idml(&zip_files(&[("designmap.xml", &map), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])).unwrap() +} + +/// Inspect the exported XML directly, without calling the IDML importer. +fn exported_index(doc: &Document) -> El { + let bytes = export_idml(doc); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut map = String::new(); + zip.by_name("designmap.xml").unwrap().read_to_string(&mut map).unwrap(); + crate::xml::parse(map.as_bytes()).unwrap().find("Index").unwrap().clone() +} + +fn topic<'a>(index: &'a El, path: &[&str]) -> &'a El { + let mut el = index; + for name in path { + el = el.find_all("Topic").find(|t| t.get("Name") == Some(*name)).unwrap(); + } + el +} + +#[test] +fn imports_synthetic_sort_keys_for_nested_topics_and_cross_references() { + let doc = import_xml( + r#" + + + + + + + + + + + + + "#, + r#" + + + + + "#, + ); + doc.check().unwrap(); + let refs = references(&doc); + assert_eq!(refs.len(), 8); + for r in [ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(4)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[], IndexRange::SuppressPageRange), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::See("Beta".into())), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::SeeAlso("Zulu".into())), + reference(&["Zoo", "Leaf"], &["Aardvark"], IndexRange::CurrentPage), + ] { + assert!(refs.contains(&r), "missing {r:?} in {refs:?}"); + } + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn exports_sort_keys_as_topic_attributes_without_changing_names() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(2)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[""], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Azure"], IndexRange::SeeAlso("Zulu".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Zulu".into())), + ]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 6, "repeated references and cross-reference targets share their topic"); + assert_eq!(topic(&index, &["Zulu"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["Beta"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Empty"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent", "É & "]).get("SortOrder"), Some(" A&<é> ")); + for (name, key, kind) in [("Alias", "Able", "See"), ("Related", "Azure", "SeeAlso")] { + for path in [&[name][..], &["Parent", name][..]] { + let t = topic(&index, path); + assert_eq!(t.get("SortOrder"), Some(key)); + let cross_ref = t.find("CrossReference").unwrap(); + assert_eq!(cross_ref.get("CrossReferenceType"), Some(kind)); + assert_eq!(cross_ref.get("ReferencedTopic"), topic(&index, &["Zulu"]).get("Self")); + } + } +} + +/// These references all belong to the same page. Fix the page resolution to isolate index +/// ordering from fonts/composition, and use the real index generator for its text and headings. +fn generated_index(doc: &Document) -> Vec<(String, String)> { + let refs: Vec<_> = references(doc).into_iter().map(|r| Resolved { r, pages: Some((0, 0)) }).collect(); + index::build(&IndexSpec { title: String::new(), ..Default::default() }, &refs, &|p| (p + 1).to_string()) +} + +#[test] +fn round_trip_preserves_generated_index_order_and_headings() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Parent", "Zulu"], &["", "Alpha"], IndexRange::CurrentPage), + reference(&["Parent", "Beta"], &[], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Delta"], IndexRange::SeeAlso("Beta".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Beta".into())), + ]); + let before = generated_index(&doc); + let expected: Vec<_> = [ + ("Index Section Head", "A"), + ("Index Level 1", "Alias See Zulu"), + ("Index Level 1", "Zulu 1"), + ("Index Section Head", "B"), + ("Index Level 1", "Beta 1"), + ("Index Section Head", "D"), + ("Index Level 1", "Related 1. See also Beta"), + ("Index Section Head", "P"), + ("Index Level 1", "Parent"), + ("Index Level 2", "Alias See Zulu"), + ("Index Level 2", "Zulu 1"), + ("Index Level 2", "Related 1. See also Beta"), + ("Index Level 2", "Beta 1"), + ] + .into_iter() + .map(|(style, text)| (style.to_string(), text.to_string())) + .collect(); + assert_eq!(before, expected); + let back = import_idml(&export_idml(&doc)).unwrap(); + back.check().unwrap(); + assert_eq!(generated_index(&back), before); + assert_eq!(back.stories.values().next().unwrap().text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn conflicting_sort_keys_keep_the_first_serialized_value_including_defaults() { + fn check(first: &[&str], second: &[&str], expected: &str) { + let doc = native(vec![reference(&["Shared"], first, IndexRange::CurrentPage), reference(&["Shared"], second, IndexRange::CurrentPage)]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 1, "conflicts do not manufacture topics"); + assert_eq!(topic(&index, &["Shared"]).get("SortOrder"), Some(expected)); + let back = import_idml(&export_idml(&doc)).unwrap(); + let refs = references(&back); + assert_eq!(refs.len(), 2); + for r in refs { + assert_eq!(r.sort.first().map_or("", String::as_str), expected); + } + assert_eq!(references(&doc)[0].sort, strings(first), "export does not edit native references"); + assert_eq!(references(&doc)[1].sort, strings(second)); + } + check(&["Alpha"], &["Omega"], "Alpha"); + check(&["Omega"], &["Alpha"], "Omega"); + check(&[], &["Alpha"], ""); + check(&[""], &["Alpha"], ""); + check(&["Alpha"], &[], "Alpha"); + check(&["Alpha"], &[""], "Alpha"); +} + +#[test] +fn shared_ancestors_and_identical_leaf_names_keep_keys_by_full_path() { + let doc = native(vec![ + reference(&["One", "Zulu"], &["Alpha", "First"], IndexRange::CurrentPage), + reference(&["One", "Zulu"], &["Beta", "Second"], IndexRange::CurrentPage), + reference(&["One", "Beta"], &["Gamma", "Third"], IndexRange::CurrentPage), + reference(&["Two", "Zulu"], &["Delta", "Fourth"], IndexRange::CurrentPage), + ]); + let index = exported_index(&doc); + assert_eq!(topic(&index, &["One"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["One", "Zulu"]).get("SortOrder"), Some("First")); + assert_eq!(topic(&index, &["One", "Beta"]).get("SortOrder"), Some("Third")); + assert_eq!(topic(&index, &["Two"]).get("SortOrder"), Some("Delta")); + assert_eq!(topic(&index, &["Two", "Zulu"]).get("SortOrder"), Some("Fourth")); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0b940aa3..debdbb12 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -55,10 +55,27 @@ impl Remote { Err(last) } - fn roundtrip(&mut self, line: &str) -> std::io::Result { + fn prepare_connection(&mut self) -> std::io::Result<()> { + if let Some((reader, writer)) = self.conn.take() { + writer.set_nonblocking(true)?; + let probe = writer.peek(&mut [0]); + writer.set_nonblocking(false)?; + let live = match probe { + Ok(0) => false, + Ok(_) => true, + Err(e) => e.kind() == std::io::ErrorKind::WouldBlock, + }; + if live { + self.conn = Some((reader, writer)); + } + } if self.conn.is_none() { self.reconnect()?; } + Ok(()) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); }; @@ -78,15 +95,15 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); - // One retry with a fresh connection (the app may have restarted). + if let Err(e) = self.prepare_connection() { + self.conn = None; + return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr)); + } let reply = match self.roundtrip(&line) { Ok(r) => r, - Err(_) => { + Err(e) => { self.conn = None; - self.roundtrip(&line).map_err(|e| { - self.conn = None; - format!("DesignCraft app at {} is not reachable: {e}", self.addr) - })? + return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr)); } }; let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; @@ -105,3 +122,145 @@ impl Backend for Remote { format!("connected to the DesignCraft app at {}", self.addr) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + fn closed_remote() -> (Remote, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let mut remote = Remote::connect(&addr).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let server = std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "document.inspect"); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap(); + stream.shutdown(std::net::Shutdown::Both).unwrap(); + drop(listener); + }); + assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null)); + server.join().unwrap(); + let stream = &remote.conn.as_ref().unwrap().1; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call"); + (remote, addr) + } + + #[test] + fn remote_executes_once_when_app_restarts_between_calls() { + let (mut remote, addr) = closed_remote(); + let listener = TcpListener::bind(&addr).unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return 0; + } + std::thread::yield_now(); + } + Err(e) => panic!("accept failed: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut applied = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + match request["method"].as_str().unwrap() { + "engine.execute" => { + assert_eq!(request["params"]["command"], "frame.create"); + applied += 1; + } + "document.inspect" => {} + method => panic!("unexpected method: {method}"), + } + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + if request["method"] == "document.inspect" { + return applied; + } + } + }); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let inspected = match &result { + Ok(_) => remote.call("document.inspect", json!({})), + Err(_) => Err("edit failed".to_string()), + }; + let applied = server.join().unwrap(); + + assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed"); + assert_eq!(inspected, Ok(json!({"applied": 1}))); + assert_eq!(applied, 1); + } + + #[test] + fn remote_reports_nothing_sent_when_reconnect_has_no_listener() { + let (mut remote, _addr) = closed_remote(); + + let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err(); + + assert!(error.contains("not reachable"), "{error}"); + assert!(error.contains("nothing was sent"), "{error}"); + assert!(!error.contains("operation may have completed"), "{error}"); + } + + #[test] + fn remote_does_not_replay_when_reply_is_lost() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let first = { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "engine.execute"); + assert_eq!(request["params"]["command"], "frame.create"); + request + }; + let mut applied = 1; + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + if request == first { + applied += 1; + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + } else { + assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}})); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + return applied; + } + } + }); + let mut remote = Remote::connect(&addr).unwrap(); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let next = remote.call("document.inspect", json!({})); + let applied = server.join().unwrap(); + + assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); + assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome"); + assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index aea3e876..de035357 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,7 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?; + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) @@ -201,7 +201,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -221,7 +221,7 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 802f615d..c51bac8e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d92fc242..dcf8abb8 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -630,7 +630,6 @@ impl Exporter<'_> { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { continue; @@ -639,7 +638,7 @@ impl Exporter<'_> { if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { continue; } - self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true); + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); } } for it in &sp.items { @@ -679,9 +678,9 @@ impl Exporter<'_> { } /// A spread or parent item, inside its structure tag when tagging. - fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) { + fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option, on_parent: bool) { if !self.opts.tagged || it.hidden || it.nonprinting { - return self.item(s, it, parent, page_name); + return self.item(s, it, parent, page); } let story = it.text_frame().map(|t| t.story); let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items(); @@ -689,7 +688,7 @@ impl Exporter<'_> { // Page furniture and decoration. let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other }; s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None))); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); return; } @@ -700,12 +699,12 @@ impl Exporter<'_> { self.tags.push(TagEntry::Story(sid)); } self.tag_story = Some(sid); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); self.tag_story = None; return; } let id = s.start_tagged(ContentTag::Other); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone()); let mut g = TagGroup::new(Tag::Figure(alt)); @@ -751,10 +750,10 @@ impl Exporter<'_> { tree } - fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { - return self.item_inner(s, it, parent, page_name); + return self.item_inner(s, it, parent, page); } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; @@ -804,11 +803,11 @@ impl Exporter<'_> { ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); - self.item_inner(s, it, parent, page_name); + self.item_inner(s, it, parent, page); s.pop(); } - fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { if it.hidden || it.nonprinting { return; } @@ -843,7 +842,7 @@ impl Exporter<'_> { s.push_clip_path(c, &FillRule::NonZero); } for c in it.children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } if clip.is_some() { s.pop(); @@ -863,7 +862,7 @@ impl Exporter<'_> { if !it.children().is_empty() { let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it); for c in it.shown_children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } for _ in 0..pushes { s.pop(); @@ -921,7 +920,7 @@ impl Exporter<'_> { } } Content::Text(tfr) => { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { @@ -966,11 +965,11 @@ impl Exporter<'_> { if let Content::Text(tfr) = &it.content && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let Some(ft) = cs.frame(it.id) { for o in &ft.objects { if let Some(obj) = st.objects.get(o.index) { - self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); + self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page); } } } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 77bb1878..6b29516f 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -66,7 +66,7 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. - pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { let doc = f.doc; let e = &it.effects; let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() }; @@ -95,7 +95,7 @@ impl Renderer { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter)); - me.draw_body(c, &moved, it, bp, xf, page_name); + me.draw_body(c, &moved, it, bp, xf, page); if grow > 0.0 && solid { c.set_transform(moved.view * xf); c.set_paint(peniko::Color::BLACK); @@ -119,7 +119,7 @@ impl Renderer { self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - me.draw_body(c, fr, it, bp, xf, page_name); + me.draw_body(c, fr, it, bp, xf, page); // Keep the body where the blurred inset silhouette is. c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); @@ -139,7 +139,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.draw_body(ctx, f, it, bp, xf, page_name); + self.draw_body(ctx, f, it, bp, xf, page); } // Directional feather: one fading ramp per side, multiplied in. let df = &e.directional_feather; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288de..2cc5c548 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -216,7 +216,7 @@ pub struct Renderer { resources: Resources, glyphs: text::GlyphCache, /// Composed stories looked up during the current render. - stories: HashMap<(StoryId, Option), Arc>, + stories: HashMap<(StoryId, Option), Arc>, pub threads: u16, pub stats: FrameStats, } @@ -408,7 +408,6 @@ impl Renderer { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); let pr = parent.pages[ppage].bounds(); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { @@ -419,7 +418,7 @@ impl Renderer { continue; } let _ = pr; - self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name)); + self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi)); } } } @@ -431,7 +430,7 @@ impl Renderer { } } - fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option) { if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { return; } @@ -458,7 +457,7 @@ impl Renderer { ctx.set_transform(f.view * xf); ctx.push_clip_layer(&bp); for c in it.children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } ctx.pop_layer(); if !it.stroke.is_none() { @@ -489,11 +488,11 @@ impl Renderer { ctx.fill_path(&c.path.to_bezpath()); ctx.pop_layer(); } - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } else { for c in it.shown_children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } if layered { @@ -520,9 +519,9 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } if effects { - self.draw_item_fx(ctx, f, it, &bp, xf, page_name); + self.draw_item_fx(ctx, f, it, &bp, xf, page); } else { - self.draw_body(ctx, f, it, &bp, xf, page_name); + self.draw_body(ctx, f, it, &bp, xf, page); } if layered { ctx.pop_layer(); @@ -531,18 +530,18 @@ impl Renderer { /// The composed story for a frame (memoised for the current render: the cache lookup /// validates the whole thread each time). - fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc { - let key = (sid, page_name.map(str::to_string)); + fn story(&mut self, f: &Frame, sid: StoryId, page: Option) -> Arc { + let key = (sid, page); if let Some(cs) = self.stories.get(&key) { return cs.clone(); } - let cs = f.cache.get(f.doc, sid, page_name); + let cs = f.cache.get(f.doc, sid, page); self.stories.insert(key, cs.clone()); cs } /// Fill, content and stroke of a leaf item (no opacity, blend or effects). - pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { let doc = f.doc; // Fill. if !it.fill.is_none() { @@ -575,7 +574,7 @@ impl Renderer { ctx.pop_layer(); } Content::Text(tf) => { - let cs = self.story(f, tf.story, page_name); + let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { match &tf.options.path { Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), @@ -1022,6 +1021,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { designcraft_images::pixel_size(bytes) } +/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster +/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display. +pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { + if is_pdf(bytes) { + return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3))); + } + designcraft_images::natural_size(bytes) +} + /// MIME type guess for encoded image bytes. /// Is this a PDF file (placed PDFs are graphics)? pub fn is_pdf(bytes: &[u8]) -> bool { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275..cc46ad72 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -117,8 +117,8 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut underlines = Vec::new(); - let mut strikes = Vec::new(); + let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); + let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { @@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let style = &cs.styles[g.style as usize]; for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { if on { - bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)))); + let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); + // One bar per stretch, as the PDF export draws them: a bar per glyph left + // anti-aliased seams between the letters. + match bars.last_mut() { + Some((color, tint, last)) + if *color == rule.color + && *tint == rule.tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((rule.color.clone(), rule.tint, r)), + } } } let outline = db.outline(&g.face, g.gid); diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index cfd08eea..b84577ea 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result { let chars: Vec = text.chars().collect(); // Paragraphs: (style, runs of (text, format)). let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())]; + let mut started = false; let mut fmt = CharFormat::default(); let mut para_styles: Vec = Vec::new(); let mut char_styles: Vec = Vec::new(); @@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result { } } "ParaStyle" => { + started = true; let v = value.trim().to_string(); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); @@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result { } let style = paras.last().map(|p| p.0.clone()).unwrap_or_default(); // Header lines before any text don't start paragraphs. - if paras.len() == 1 && paras[0].1.is_empty() { + if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) { i += 1; continue; } @@ -243,6 +245,18 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + #[test] + fn tagged_text_keeps_a_leading_empty_paragraph() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let mut story = Story::new(StoryId(0)); + story.insert(0, "\nText"); + story.paras[0].style = "Head".into(); + story.paras[1].style = "Body".into(); + let back = import(tagged_text(&doc, &story).as_bytes()).unwrap(); + assert_eq!(back.story.text, story.text); + assert_eq!(back.story.paras, story.paras); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f8..f3de390a 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -63,11 +63,16 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// Presses in quick succession at about the same spot, this one included: 2 on a + /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). + /// 0 or 1 is a single press. + #[serde(default)] + pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default() } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; @@ -335,6 +340,10 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The Pen over an open path's end point: a click continues the path. + PenContinue, + /// Over a type-on-a-path bracket: dragging it moves where the text starts or ends. + PathBracket, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51c..21e90f73 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,8 +1,9 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from +//! there; dragging from it first reshapes its direction lines. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; @@ -19,6 +20,45 @@ pub struct PenTool { hover: Point, /// Smart guides in canvas coordinates, as `snap_point` returned them. guides: Vec, + /// Continuing an existing path: the subpath the new anchors go on. + subpath: Option, + /// The press was on the continued path's end point: a drag reshapes its direction lines + /// instead of adding an anchor. + editing_end: bool, + /// The press on the end point has been dragged. + end_moved: bool, +} + +/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last +/// anchor). The selection is checked first, then the frontmost item under the pointer. +fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> { + let tol = cx.tol(6.0); + let mut ids = cx.selection.items.clone(); + if let Some((_, id)) = cx.hit(p) + && !ids.contains(&id) + { + ids.push(id); + } + for id in ids { + let Some(it) = cx.doc.item(id) else { continue }; + if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) { + continue; + } + let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue }; + for (si, sp) in it.path.subpaths.iter().enumerate() { + if sp.closed { + continue; + } + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if (xf * last.p - p).hypot() <= tol { + return Some((id, si, true)); + } + if (xf * first.p - p).hypot() <= tol { + return Some((id, si, false)); + } + } + } + None } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -32,9 +72,35 @@ impl PenTool { self.anchors.clear(); self.dragging = false; self.guides.clear(); + self.subpath = None; + self.editing_end = false; + self.end_moved = false; vec![] } + /// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors + /// so far are its other end and the end point, in spread coordinates. + fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec { + let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] }; + let Some(sp) = it.path.subpaths.get(si) else { return vec![] }; + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] }; + let xf = cx.doc.parent_xf(&loc) * it.xf; + let at = |a: &designcraft_geom::Anchor, flip: bool| { + let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; + (xf * a.p, xf * i, xf * o) + }; + // From the first anchor the path is reversed first, so the new anchors follow on. + let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + self.path = Some(id.0); + self.subpath = Some(si); + self.spread = Some(loc.spread); + self.anchors = vec![other, end]; + self.editing_end = true; + self.end_moved = false; + self.dragging = true; + if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + } + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { if shift && let Some(last) = self.anchors.last() { @@ -53,7 +119,13 @@ impl PenTool { let anchors: Vec = self.anchors.iter().map(anchor_json).collect(); vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } - Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))], + Some(id) => { + let mut p = json!({"id": id, "anchor": anchor_json(last)}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.appendAnchor".into(), p)] + } None => vec![], } } @@ -93,12 +165,24 @@ impl Tool for PenTool { && (first.0 - sp).hypot() <= cx.tol(6.0) { let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] + } None => vec![], }; self.finish(); return out; } + // On an open path's end point: continue that path. + if self.anchors.is_empty() + && let Some((id, si, at_end)) = endpoint_at(cx, ev.pos) + { + return self.continue_from(cx, id, si, at_end); + } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); @@ -108,6 +192,7 @@ impl Tool for PenTool { vec![] } PointerKind::Drag => { + self.end_moved |= self.editing_end; if let Some(a) = self.anchors.last_mut() { // Smooth point: out handle follows the pointer, in handle mirrors it. a.2 = sp; @@ -120,6 +205,17 @@ impl Tool for PenTool { return vec![]; } self.dragging = false; + // The continued path's end point: a drag set its direction lines. + if std::mem::take(&mut self.editing_end) { + let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; + if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + return vec![]; + } + return vec![Action::Exec( + "path.setAnchor".into(), + json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + )]; + } self.commit_anchor() } _ => vec![], @@ -165,8 +261,8 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } } fn busy(&self) -> bool { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c350..fc9a8c31 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -38,6 +38,11 @@ enum Drag { start: Point, cur: Point, }, + /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). + Bracket { + id: ItemId, + end: bool, + }, Anchor { id: u64, si: usize, @@ -51,6 +56,50 @@ enum Drag { }, } +/// Half the length of a path-type bracket, in screen pixels. +const BRACKET_PX: f64 = 9.0; + +/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its +/// brackets' arc lengths (start, end). +fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> { + let it = cx.doc.item(id)?; + let pt = it.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip); + let end = pt.end.unwrap_or(warp.length()).min(warp.length()); + Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end)) +} + +/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas). +fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> { + let (p, a) = warp.at(s)?; + let c = xf * p; + let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c; + let n = designcraft_geom::Vec2::new(-t.y, t.x); + let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX); + Some((c - n, c + n)) +} + +/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end +/// bracket). +fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> { + let tol = cx.tol(BRACKET_PX); + cx.selection.items.iter().find_map(|id| { + let (warp, xf, start, end) = path_type_of(cx, *id)?; + if cx.doc.item(*id).is_some_and(|it| it.locked) { + return None; + } + let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol); + // The end bracket first: with both brackets at one point, it can still be pulled back. + if near(end) { + Some((*id, true)) + } else if near(start) { + Some((*id, false)) + } else { + None + } + }) +} + /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { anchor_at_in(cx, &cx.selection.items, p) @@ -378,6 +427,13 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + // Type on a path: a bracket moves where the text starts or ends. + if !ev.mods.shift + && let Some((id, end)) = bracket_at(cx, p) + { + self.drag = Drag::Bracket { id, end }; + return vec![Action::Begin("Move Bracket".into())]; + } if self.direct && let Some((id, si, ai, handle)) = anchor_at(cx, p) { @@ -437,6 +493,14 @@ impl Tool for SelectionTool { } } PointerKind::Drag => match self.drag.clone() { + Drag::Bracket { id, end } => { + let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] }; + if xf.determinant().abs() < 1e-12 { + return vec![]; + } + let s = warp.nearest(xf.inverse() * p); + vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))] + } Drag::Pending { start, hit } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; @@ -682,7 +746,9 @@ impl Tool for SelectionTool { let release = self.shift_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => { + vec![Action::Commit] + } Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -739,15 +805,19 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), + Drag::None | Drag::Bracket { .. } => brackets(cx), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) { + return Cursor::PathBracket; + } if self.direct { return Cursor::ArrowHollow; } @@ -769,6 +839,27 @@ impl Tool for SelectionTool { } } +/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a +/// line across the path with a short tick pointing at the text between them. +fn brackets(cx: &ToolContext) -> Vec { + let mut out = Vec::new(); + for id in &cx.selection.items { + let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue }; + let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color); + for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] { + let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue }; + out.push(Overlay::Line { a, b, color, dashed: false }); + // The tick at the outer end points along the path towards the text. + if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) { + let dir = xf * p1 - xf * p0; + let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6); + out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false }); + } + } + } + out +} + fn handle_cursor(h: usize) -> Cursor { match h { 0 | 4 => Cursor::ResizeNwSe, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da..6776902a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,174 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn pen_continues_an_open_path_from_either_end_point() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor { + p: Point::new(200.0, 100.0), + h_in: Point::new(170.0, 80.0), + h_out: Point::new(230.0, 120.0), + kind: designcraft_geom::AnchorKind::Smooth, + }, + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut t = create("pen"); + // The cursor says the end point continues the path; elsewhere it's the plain pen. + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); + assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); + // Clicking the last anchor adds nothing yet; the next click appends to that path. + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); + t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); + assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); + // Clicking the first anchor of the other end closes it. + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + // From the first anchor, the path is reversed first; a drag there sets its direction lines. + let mut t = create("pen"); + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); + assert_eq!( + a, + vec![Action::Exec( + "path.setAnchor".into(), + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + )] + ); + // Closed paths and locked paths aren't continued. + let mut d2 = d.clone(); + d2.item_mut(id).unwrap().locked = true; + let cx = ctx(&d2, &s, &c, &l); + let t = create("pen"); + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); +} + +#[test] +fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( + vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], + false, + )); + let mut it = Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); + st.frames = vec![id]; + d.stories.insert(sid, Arc::new(st)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y); + // Not selected: no brackets. + let none = Selection::default(); + assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty()); + let s = Selection { items: vec![id], ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let mut t = create("selection"); + // Both brackets drawn across the path at 140 and 400 (a line and a tick each). + let o = t.overlays(&cx); + let xs: Vec = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect(); + assert_eq!(xs, [140.0, 400.0], "{o:?}"); + assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket); + assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket); + // Drag the start bracket: its distance along the path follows the pointer, off the path too. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0))); + let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0))); + assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]); + // The end bracket. + t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0))); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0))); + let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") }; + assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}"); +} + +#[test] +fn eyedropper_samples_a_placed_image_and_matches_other_objects() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let photo = ItemId(d.alloc()); + let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.assets.insert( + designcraft_doc::AssetId(1), + std::sync::Arc::new(designcraft_doc::Asset { + id: designcraft_doc::AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // An unfilled text frame over part of the photo lets it show through. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); + let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); + d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + // The photo's layer is locked: sampling it still works. + d.layers.iter_mut().for_each(|l| l.locked = true); + let s = Selection { items: vec![target], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("eyedropper"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); + assert_eq!(a, vec![sample(120.0, 130.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); + assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // A filled object is matched as before (here on a locked layer: not reachable, so nothing). + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); + let mut d2 = d.clone(); + d2.layers.iter_mut().for_each(|l| l.locked = false); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); + // Vector graphics and missing assets must retain the ordinary attribute-matching path. + for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { + std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}"); + } + d2.assets.clear(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f23..f166cf8d 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,19 +50,21 @@ impl Tool for TypeTool { && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + // Clicks in a row select a word, a sentence, a paragraph, then the whole story. + let unit = match (ev.kind, ev.clicks) { + (PointerKind::DoubleClick, _) => Some("text.selectWord"), + (_, 3) => Some("text.selectSentence"), + (_, 4) => Some("text.selectParagraph"), + (_, 5..) => Some("text.selectStory"), + _ => None, + }; // Whole rows/columns selected: wait to see whether this is a drag. - if ev.kind == PointerKind::Down && !ev.mods.shift && whole_rows_or_cols(cx) { + if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = if ev.kind == PointerKind::DoubleClick { - "text.selectWord" - } else if ev.mods.shift { - "text.extendTo" - } else { - "text.placeCaret" - }; + let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index fbb1df9b..e0bcd24f 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,6 +1,7 @@ //! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets //! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply -//! its attributes to the selection. +//! its attributes to the selection, or a placed image to colour the selection (text or objects) +//! with the image's colour under the pointer. use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; @@ -247,6 +248,43 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } +/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats +/// still block an image behind them, but use attribute matching instead of pixel sampling. +fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { + use designcraft_doc::{Content, Item}; + fn shows(d: &designcraft_doc::Document, it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + if let Content::Group { items } = &it.content { + return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); + } + let graphic = matches!(it.content, Content::Graphic(_)); + // Unfilled frames (a text frame over the photo) let the image show through. + if !graphic && it.fill.is_none() { + return None; + } + if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) { + return None; + } + Some(match &it.content { + Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()), + _ => false, + }) + } + let (sr, at) = cx.layout.spread_at(p)?; + let sp = cx.doc.spread(sr)?; + let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| cx.doc.layer_rank(i.layer)); + let image = items + .into_iter() + .rev() + .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?; + image.then_some((sr, at)) +} + #[derive(Default)] pub struct EyedropperTool; @@ -258,8 +296,13 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } + let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). + if selected && let Some((sr, at)) = image_under(cx, ev.pos) { + return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; - if cx.selection.items.is_empty() && cx.selection.text.is_none() { + if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; } vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 52cd054b..76b5f89b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft-ui-egui" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it" @@ -9,6 +10,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth workspace = true [dependencies] +craft-ui = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } @@ -27,7 +29,9 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } designcraft-idml = { workspace = true } +zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd6144..077ae294 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -352,7 +352,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, + Cursor::Pen | Cursor::PenContinue => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, @@ -978,6 +979,28 @@ fn draw_text_selection( if range.is_empty() { continue; } + // Type on a path: the highlight bends with the text. + if let Some(map) = compose::PathTextMap::new(it, ft) { + for l in &ft.lines { + let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end)); + if s >= e { + continue; + } + for q in compose::highlight_quads(l, s, e, false) { + let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x)); + let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y)); + // Short slices, each turned with the path. + let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); + let poly = + [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + } + } + } + continue; + } let mut quads = Vec::new(); let mut glyphs = Vec::new(); for (li, l) in ft.lines.iter().enumerate() { @@ -1009,8 +1032,10 @@ fn draw_text_selection( let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - let p0 = xf.to_screen(m * Point::new(x, bl - asc)); - let p1 = xf.to_screen(m * Point::new(x, bl + desc)); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); @@ -1341,6 +1366,19 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } +/// Presses in a row, this one included: each within egui's double-click delay and distance of the +/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). +fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); + let n = match last { + Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), + _ => 1, + }; + ui.data_mut(|d| d.insert_temp(id, (now, at, n))); + n +} + fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1383,20 +1421,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); - let (pressed, released, origin, latest, dbl) = ui.input(|i| { - ( - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.pointer.press_origin(), - i.pointer.latest_pos(), - i.pointer.button_double_clicked(egui::PointerButton::Primary), - ) - }); + let (pressed, released, origin, latest, now) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); + let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); + let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; + events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); down = true; } if down @@ -1404,7 +1437,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1414,8 +1447,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m }); - events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { ui.data_mut(|d| d.insert_temp(hold_id, (start, fired))); @@ -1428,10 +1461,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } if down && released { let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1443,8 +1476,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if resp.clicked() || resp.drag_started() { resp.request_focus(); } - // Keyboard for the active tool. - if ui.ctx().text_edit_focused() && !resp.has_focus() { + // Both split panes show the same document, but only the focused pane dispatches its keys. + if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } let evs = ui.input(|i| i.events.clone()); @@ -1798,6 +1831,86 @@ mod tests { } } + /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a + /// row must land within the double-click delay). + fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, id) + } + + /// The screen point over the first line of frame `id` where a click puts the caret at a byte + /// in `bytes`. + fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range) -> Pos2 { + let mut x = 74.0; + let p = loop { + assert!(x < 400.0, "no point on the first line hits {bytes:?}"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap(); + if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) { + break Point::new(x, 80.0); + } + x += 1.0; + }; + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let app = h.state(); + let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + } + + fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range { + h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default() + } + + #[test] + fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + let text = "One two. Three four! Five six.\nSecond para here."; + let (mut h, id) = typing(text); + // Inside "Three", the second sentence: its start is not the paragraph's. + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + let mut got = vec![]; + for _ in 0..5 { + click(&mut h, p); + let r = selected(&h); + got.push(text[r].to_string()); + } + assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert!(got[0].is_empty(), "one click places the caret: {got:?}"); + } + + #[test] + fn slow_clicks_do_not_add_up() { + let (mut h, id) = typing("One two. Three four!"); + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + for _ in 0..3 { + click(&mut h, p); + h.run_steps(30); + } + assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc..c2f1cb48 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -489,9 +489,17 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The widest a document tab grows: a longer name is shortened so its close button and the +/// tabs after it stay in view. +const DOC_TAB_MAX_W: f32 = 260.0; +/// Close button and padding around a tab's title. +const DOC_TAB_PAD: f32 = 44.0; + pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + let max_w = DOC_TAB_MAX_W.min(bar.width()); + let close_label = crate::i18n::tr(&app.ui.language, "Close"); ui.painter().rect_filled(bar, 0.0, t.tab_strip); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); let mut x = bar.min.x; @@ -504,14 +512,28 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let is_active = Some(i) == active; // `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there). let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" }; - let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title), zoom * 100.0); - let galley = crate::rtl::plain(ui.ctx(), &label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim }); - let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 }); + let color = if is_active { t.text_strong } else { t.text_dim }; + // The name gives way first; the dirty marker and zoom stay readable. + let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color); + let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title)); + let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x; + let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max); + let elided = galley.elided; + let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let resp = if elided { + resp.on_hover_ui(|ui| { + let _ = crate::rtl::label(ui, title); + }) + } else { + resp + }; ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip }); let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}"))); ui.painter().text( cr.center(), egui::Align2::CENTER_CENTER, @@ -519,7 +541,11 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { egui::FontId::proportional(14.0), if cresp.hovered() { t.text_strong } else { t.text_dim }, ); - ui.painter().galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let name_w = galley.size().x; + // Clipped too, for a window narrower than the zoom label. + let text = ui.painter().with_clip_rect(r); + text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); if cresp.clicked() { close = Some(i); @@ -783,6 +809,27 @@ mod tests { h.get_by_label(label).rect() } + /// A very long document name made its tab grow past the window, hiding the close button + /// and the tabs after it (#603). + #[test] + fn long_document_names_keep_tabs_short_and_close_buttons_visible() { + let latin = "Quarterly report with a very long file name ".repeat(6); + let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6); + let mut app = app(); + for title in [latin.as_str(), hebrew.as_str(), "Short"] { + app.run("file.new", serde_json::json!({ "title": title })).unwrap(); + } + let h = test_window::open(app, vec2(1200.0, 600.0)); + for title in [latin.as_str(), hebrew.as_str()] { + let tab = rect(&h, title); + assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width()); + let close = rect(&h, &format!("Close {title}")); + assert!(tab.contains_rect(close), "close button {close:?} outside its tab {tab:?}"); + } + let (first, short) = (rect(&h, &latin), rect(&h, "Short")); + assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 33e7c1b9..5a4de005 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -147,7 +147,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) { + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { return err(e); } app.after_engine(); @@ -278,7 +278,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.ui.control_bar = v; } if p.get("closePanel").is_some() { - app.ui.open_panel = None; + r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { app.ui.dock_expanded = v; @@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { @@ -353,6 +352,8 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => wrap(app.run("app.exportPng", p.clone())), "app.quit" => { + // Automation must not wait on a prompt: quit without asking about unsaved changes. + app.quit_without_asking = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3ba..695a66af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing +/// dialog puts the document back as it was. +pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { + app.session.cancel_interaction(); + } + app.cancel_pdf_import(); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2078,6 +2085,9 @@ pub fn confirm(app: &mut DesignApp) -> Result { "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); + if d.b("quitting") { + return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); + } let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; if !d.b("discard") { @@ -2164,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + STEP_AND_REPEAT => { + // The preview goes; the copies are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3555,6 +3579,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a +/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows +/// the copies on the page while the dialog is open. +fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + for (k, v) in [ + ("count", json!("1")), + ("rows", json!("1")), + ("columns", json!("1")), + ("vertical", json!(format_measure(12.0, vu))), + ("horizontal", json!(format_measure(12.0, hu))), + ] { + d.fields.entry(k).or_insert(v); + } + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0))); + egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let grid = d.b("grid"); + let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] }; + for (k, l) in fields { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 70.0); + ui.end_row(); + } + crate::rtl::label(ui, ""); + check(ui, d, "grid", &tr("Create as a grid")); + ui.end_row(); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0))); + ui.horizontal(|ui| { + for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 80.0); + ui.add_space(12.0); + } + }); + }); + ui.add_space(32.0); + check(ui, d, "preview", &tr("Preview")); + }); + step_and_repeat_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units +/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0. +fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim()); + // Fields not shown yet (a dialog confirmed over the control channel) take the defaults. + let whole = |k: &str| { + let s = d.s(k); + if s.trim().is_empty() { Ok(1) } else { s.trim().parse::().map_err(|_| invalid(&s)) } + }; + let measure = |k: &str, u: Unit| { + let s = d.s(k); + if s.trim().is_empty() { + return Ok(12.0); + } + parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s)) + }; + let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?); + Ok(if d.b("grid") { + json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy}) + } else { + json!({"count": whole("count")?, "dx": dx, "dy": dy}) + }) +} + +/// Preview on: the copies show on the page (redone when a field changes); off: they go. +fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match step_and_repeat_params(app, d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Step and Repeat") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("edit.stepAndRepeat", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -3741,6 +3879,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let open = |app: &mut DesignApp| { + crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT)); + }; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let (n0, u0) = (items(&app), undo(&app)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + open(&mut app); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + // Preview: the copies show, and change with the fields, without an undo step. + set(&mut app, "count", json!("5")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!((items(&app), undo(&app)), (n0 + 5, u0)); + set(&mut app, "count", json!("3")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0, "Preview off takes them away"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + // Cancel puts the document back. + cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((items(&app), undo(&app)), (n0, u0)); + // OK after a preview makes the copies once, as one undo step, 0.75 in apart. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "count", json!("2")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let r = confirm(&mut app).unwrap(); + assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1)); + let last = r["ids"][1].as_u64().unwrap(); + let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y(); + assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}"); + // Create as a grid: Rows × Columns cells, the original being one. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "grid", json!(true)); + set(&mut app, "rows", json!("2")); + set(&mut app, "columns", json!("3")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Rows:"); + visible_label(&labels, screen, "Columns:"); + assert_eq!(confirm(&mut app).unwrap()["created"], 5); + // A value that isn't a measure keeps the dialog open and says so. + open(&mut app); + set(&mut app, "vertical", json!("up")); + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("status"), "Invalid value: up"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fdf20e6c..0d581e99 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -49,6 +49,9 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if crate::panel_docking::show(app, ui) { + return; + } let t = Tokens::get(ui.ctx()); // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. if app.ui.dock_expanded { @@ -66,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.ctx(), crate::i18n::tr(&app.ui.language, label), semibold(11.5), - if active { Color32::from_rgb(0xf3, 0xf3, 0xf3) } else { t.text_dim }, + if active { t.text_strong } else { t.text_dim }, ); let w = g.size().x + 26.0; let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); @@ -75,13 +78,31 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() { + let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( + ui, + r, + |_, _| {}, + ); + if response.clicked() { app.ui.dock_tab = id.to_string(); } + crate::panel_docking::legacy_tab(app, ui, id, &response); x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); + let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); + if menu.hovered() { + ui.painter().rect_filled(menu_r, 3.0, t.hover); + } icons::paint(ui.painter(), menu_r, "menu", t.icon); + let tab = app.ui.dock_tab.clone(); + egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if tab == "pages" { + panels::pages::panel_menu(app, ui); + ui.separator(); + } + crate::panel_docking::panel_menu(app, ui, &tab); + }); egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { "pages" => panels::pages::show(app, ui), @@ -100,13 +121,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { for (id, label, icon) in ICON_PANELS { let open = app.ui.open_panel.as_deref() == Some(*id); let floats = app.ui.floating.iter().any(|(p, _)| p == id); - if icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).clicked() { + let response = + icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); + if response.clicked() { if floats { ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); } else { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } } + crate::panel_docking::legacy_tab(app, ui, id, &response); ui.add_space(1.0); } }); @@ -123,6 +147,9 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { /// A panel opened from the icon column, shown as a floating flyout next to the dock. pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let Some(id) = app.ui.open_panel.clone() else { return }; let t = Tokens::get(ctx); let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -170,6 +197,9 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { /// The contents of an icon-column panel. pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { match id { + "properties" => panels::properties::show(app, ui), + "pages" => panels::pages::show(app, ui), + "layers" => panels::layers::show(app, ui), "swatches" => panels::swatches::show(app, ui), "paragraphStyles" => panels::styles::paragraph(app, ui), "characterStyles" => panels::styles::character(app, ui), @@ -209,20 +239,27 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { /// Tear panel `id` off the dock into a floating window at `at`. pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if app.ui.open_panel.as_deref() == Some(id) { - app.ui.open_panel = None; + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { + app.status(error); } - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); } /// Put a floating panel back in the dock's icon column. pub fn dock_panel(app: &mut DesignApp, id: &str) { + if app.ui.docking.is_some() { + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { + app.status(error); + } + return; + } app.ui.floating.retain(|(p, _)| p != id); } /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -291,3 +328,96 @@ fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); } + +#[cfg(test)] +mod visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + /// Opt-in evidence for native screenshot review; ordinary test runs stay headless and fast. + #[test] + fn capture_dock_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for brightness in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), "en"); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(brightness)); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let region = serde_json::json!({ + "screen": [width, 640.0], "scale": scale, + "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + }); + std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); + } + } + } + } + + fn luminance(c: Color32) -> f32 { + let linear = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b()) + } + + #[test] + fn selected_dock_tab_has_readable_rendered_contrast_in_every_theme() { + for brightness in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let tokens = Tokens::for_brightness(brightness); + crate::theme::apply(&ctx, &tokens); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == "Properties" => Some(text), + _ => None, + }) + .expect("the active Properties tab is painted"); + let color = text.galley.job.sections.first().expect("tab text has a format").format.color; + let a = luminance(color); + let b = luminance(tokens.panel); + let contrast = (a.max(b) + 0.05) / (a.min(b) + 0.05); + assert!(contrast >= 4.5, "{brightness:?} at {width}: selected tab contrast is {contrast:.2}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d0..5252c388 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), + ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), @@ -646,6 +648,15 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Fill (X)", ["Fill (X)", "Fill (X)", "Fill (X)", "Fill (X)", "填色 (X)"]), ("Stroke (X)", ["Stroke (X)", "Stroke (X)", "Stroke (X)", "Stroke (X)", "描边 (X)"]), ("Apply None (/)", ["Apply None (/)", "Apply None (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), + ( + "Default Fill and Stroke", + ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ( + "Toggle Fill and Stroke", + ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ), ("Reverse the gradient", ["Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Style source", "Style source", "Style source", "样式源"]), ("Open", ["Open", "Open", "Open", "Open", "打开"]), @@ -699,6 +710,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("for", ["for", "pour", "por", "に対して", "行"]), ("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]), + ("Quit DesignCraft", ["DesignCraft beenden", "Quitter DesignCraft", "Salir de DesignCraft", "DesignCraft を終了", "退出 DesignCraft"]), ("Accept All Changes", ["Accept All Changes", "Accept All Changes", "Accept All Changes", "Accept All Changes", "接受所有更改"]), ("Activate Document", ["Activate Document", "Activate Document", "Activate Document", "Activate Document", "激活文档"]), ("Add Anchor Point", ["Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "添加锚点"]), @@ -738,6 +750,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]), + ("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]), + ("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]), + ("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]), + ("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]), + ("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]), + ("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]), + ("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]), + ("Isolate Blending", ["Füllmethode isolieren", "Isoler le mode de fusion", "Aislar fusión", "描画モードを分離", "分离混合"]), + ("Knockout Group", ["Aussparungsgruppe", "Groupe de masquage", "Grupo de exclusión", "グループの抜き", "挖空组"]), + ("Object:", ["Objekt:", "Objet :", "Objeto:", "オブジェクト:", "对象:"]), + ("Group:", ["Gruppe:", "Groupe :", "Grupo:", "グループ:", "编组:"]), + ( + "Sample Color (Eyedropper)", + ["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"], + ), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", @@ -2810,6 +2838,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Slug", ["Infobereich", "Ligne-bloc", "Indicaciones", "印刷可能領域", "辅助信息区"]), + ("Presentation", ["Präsentation", "Présentation", "Presentación", "プレゼンテーション", "演示文稿"]), + ("Extras", ["Extras", "Options supplémentaires", "Extras", "エクストラ", "附加"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd..a182d09e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Reverse Path", "عكس المسار"), + ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), @@ -149,6 +151,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "إشارات مرجعية"), ("Show All Menu Items", "إظهار كل عناصر القائمة"), ("About DesignCraft", "حول DesignCraft"), + ("Quit DesignCraft", "إنهاء DesignCraft"), ("Welcome to DesignCraft", "مرحباً بك في DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."), (" New file ", " ملف جديد "), @@ -520,6 +523,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "النص المرتفع"), ("Tag the selection", "إضافة وسم للتحديد"), ("The structure is valid.", "البنية صالحة."), + ("Toggle Fill and Stroke", "التبديل بين التعبئة والحد"), ("Toggle double column", "تبديل التخطيط بعمودين"), ("Type:", "النوع:"), ("Underline", "تسطير"), @@ -844,6 +848,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "القص إلى:"), ("Cross-Reference Format", "تنسيق المرجع المتقاطع"), ("Cursor Key:", "مفتاح المؤشر:"), + ("Default Fill and Stroke", "التعبئة والحد الافتراضيان"), ("Default Fill and Stroke (D)", "التعبئة والحد الافتراضيان (D)"), ("Default View:", "العرض الافتراضي:"), ("Delete Column", "حذف العمود"), @@ -985,6 +990,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "تعارض أسماء الأنماط"), ("Stylistic Sets", "المجموعات الأسلوبية"), ("Subdivisions:", "التقسيمات الفرعية:"), + ("Swap Fill and Stroke", "تبديل التعبئة والحد"), ("Swap Fill and Stroke (Shift+X)", "تبديل التعبئة والحد (Shift+X)"), ("Synchronize", "مزامنة"), ("System default", "افتراضي النظام"), @@ -1040,6 +1046,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Repeat", "تكرار"), + ("Count:", "العدد:"), + ("Create as a grid", "إنشاء كشبكة"), + ("Rows:", "الصفوف:"), + ("Columns:", "الأعمدة:"), + ("Vertical:", "رأسي:"), + ("Horizontal:", "أفقي:"), + ("Invalid value", "قيمة غير صالحة"), + ("Isolate Blending", "عزل المزج"), + ("Knockout Group", "مجموعة مفرغة"), + ("Object:", "الكائن:"), + ("Group:", "المجموعة:"), + ("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6b..75ece03f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Reverse Path", "Inverti tracciato"), + ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), @@ -376,6 +378,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Riempimento (X)"), ("Stroke (X)", "Traccia (X)"), ("Apply None (/)", "Applica nessuno (/)"), + ("Default Fill and Stroke", "Riempimento e traccia predefiniti"), + ("Swap Fill and Stroke", "Scambia riempimento e traccia"), + ("Toggle Fill and Stroke", "Alterna riempimento e traccia"), ("Reverse the gradient", "Inverti la sfumatura"), ("Style source", "Origine stile"), ("Open", "Apri"), @@ -396,6 +401,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"), ("for", "per"), ("About DesignCraft", "Informazioni su DesignCraft"), + ("Quit DesignCraft", "Esci da DesignCraft"), ("Accept All Changes", "Accetta tutte le modifiche"), ("Activate Document", "Attiva documento"), ("Add Anchor Point", "Aggiungi punto di ancoraggio"), @@ -411,6 +417,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Repeat", "Ripeti"), + ("Count:", "Conteggio:"), + ("Create as a grid", "Crea come griglia"), + ("Rows:", "Righe:"), + ("Columns:", "Colonne:"), + ("Vertical:", "Verticale:"), + ("Horizontal:", "Orizzontale:"), + ("Invalid value", "Valore non valido"), + ("Isolate Blending", "Isola fusione"), + ("Knockout Group", "Gruppo di foratura"), + ("Object:", "Oggetto:"), + ("Group:", "Gruppo:"), + ("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), @@ -1492,4 +1511,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Presentation", "Presentazione"), ]; diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4d..642b4a4c 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Reverse Path", "パスの方向反転"), + ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), @@ -378,6 +380,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "塗り (X)"), ("Stroke (X)", "線 (X)"), ("Apply None (/)", "カラーなしを適用 (/)"), + ("Default Fill and Stroke", "塗りと線をデフォルトに戻す"), + ("Swap Fill and Stroke", "塗りと線を入れ替え"), + ("Toggle Fill and Stroke", "塗りと線を切り替え"), ("Reverse the gradient", "グラデーションを反転"), ("Style source", "スタイルの同期元"), ("Open", "開く"), @@ -398,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "最大10ページのスプレッド"), ("for", "に対して"), ("About DesignCraft", "DesignCraft について"), + ("Quit DesignCraft", "DesignCraft を終了"), ("Accept All Changes", "すべての変更を採用"), ("Activate Document", "ドキュメントをアクティブ化"), ("Add Anchor Point", "アンカーポイントを追加"), @@ -413,6 +419,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Repeat", "繰り返し"), + ("Count:", "繰り返し数:"), + ("Create as a grid", "グリッドとして作成"), + ("Rows:", "行:"), + ("Invalid value", "無効な値"), + ("Isolate Blending", "描画モードを分離"), + ("Knockout Group", "グループの抜き"), + ("Object:", "オブジェクト:"), + ("Group:", "グループ:"), + ("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), @@ -1549,15 +1565,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Insert Special Character/Quotation Marks", "書式/特殊文字を挿入/引用符"), ("Type/Insert Special Character/Symbols", "書式/特殊文字を挿入/記号"), ("Type/Insert White Space", "書式/空白文字を挿入"), - ("Type/Proof Setup", "書式/カラー校正設定"), - ("Type/Rotate Spread", "書式/スプレッドを回転"), - ("Type/Separations Preview", "書式/分版プレビュー"), ("Type/Story Direction", "書式/組み方向"), ("Type/Text Variables", "書式/テキスト変数"), ("Type/Track Changes", "書式/変更を記録"), ("Type/Type on a Path", "書式/パス上のテキスト"), ("View/Display Performance", "表示/表示品質"), + ("View/Extras", "表示/表示補助"), ("View/Grids & Guides", "表示/グリッドとガイド"), + ("View/Proof Setup", "表示/カラー校正設定"), + ("View/Rotate Spread", "表示/スプレッドを回転"), + ("View/Screen Mode", "表示/スクリーンモード"), + ("View/Separations Preview", "表示/分版プレビュー"), + ("View/Structure", "表示/構造"), ("Window/Arrange", "ウィンドウ/配置"), ("Window/Interactive", "ウィンドウ/インタラクティブ"), ("Window/Interface Color Theme", "ウィンドウ/インターフェイスのカラーテーマ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc..982dfb20 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Reverse Path", "Inverter demarcador"), + ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), @@ -149,6 +151,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "Indicadores"), ("Show All Menu Items", "Mostrar todos os itens de menu"), ("About DesignCraft", "Sobre o DesignCraft"), + ("Quit DesignCraft", "Encerrar o DesignCraft"), ("Welcome to DesignCraft", "Bem-vindo ao DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "Layout de página para impressão e tela — rápido, aberto e scriptável."), (" New file ", " Novo arquivo "), @@ -519,6 +522,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "Sobrescrito"), ("Tag the selection", "Marcar a seleção"), ("The structure is valid.", "A estrutura é válida."), + ("Toggle Fill and Stroke", "Alternar entre preenchimento e traçado"), ("Toggle double column", "Alternar coluna dupla"), ("Type:", "Tipo:"), ("Underline", "Sublinhado"), @@ -846,6 +850,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "Cortar para:"), ("Cross-Reference Format", "Formato de referência cruzada"), ("Cursor Key:", "Tecla do cursor:"), + ("Default Fill and Stroke", "Preenchimento e traçado padrão"), ("Default Fill and Stroke (D)", "Preenchimento e traçado padrão (D)"), ("Default View:", "Exibição padrão:"), ("Delete Column", "Excluir coluna"), @@ -992,6 +997,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "Conflitos de nomes de estilos"), ("Stylistic Sets", "Conjuntos estilísticos"), ("Subdivisions:", "Subdivisões:"), + ("Swap Fill and Stroke", "Alternar preenchimento e traçado"), ("Swap Fill and Stroke (Shift+X)", "Alternar preenchimento e traçado (Shift+X)"), ("Synchronize", "Sincronizar"), ("System default", "Padrão do sistema"), @@ -1044,6 +1050,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Repeat", "Repetir"), + ("Count:", "Contagem:"), + ("Create as a grid", "Criar como grade"), + ("Rows:", "Linhas:"), + ("Columns:", "Colunas:"), + ("Vertical:", "Vertical:"), + ("Horizontal:", "Horizontal:"), + ("Invalid value", "Valor inválido"), + ("Isolate Blending", "Isolar mesclagem"), + ("Knockout Group", "Grupo de vazamento"), + ("Object:", "Objeto:"), + ("Group:", "Grupo:"), + ("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89b..f3bc8fb3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Reverse Path", "Розвернути контур"), + ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), @@ -379,6 +381,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Заливка (X)"), ("Stroke (X)", "Обведення (X)"), ("Apply None (/)", "Без кольору (/)"), + ("Default Fill and Stroke", "Заливка та обведення за замовчуванням"), + ("Swap Fill and Stroke", "Змінити заливку та обведення"), + ("Toggle Fill and Stroke", "Перемкнути заливку та обведення"), ("Reverse the gradient", "Змінити напрямок градієнта"), ("Style source", "Джерело стилів"), ("Open", "Відкрити"), @@ -399,6 +404,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Розвороти до 10 сторінок"), ("for", "для"), ("About DesignCraft", "Про DesignCraft"), + ("Quit DesignCraft", "Завершити DesignCraft"), ("Accept All Changes", "Прийняти всі зміни"), ("Activate Document", "Активувати документ"), ("Add Anchor Point", "Додати опорну точку"), @@ -414,6 +420,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Repeat", "Повторення"), + ("Count:", "Кількість:"), + ("Create as a grid", "Створити як сітку"), + ("Rows:", "Рядки:"), + ("Columns:", "Стовпці:"), + ("Vertical:", "Вертикально:"), + ("Horizontal:", "Горизонтально:"), + ("Invalid value", "Неприпустиме значення"), + ("Isolate Blending", "Ізолювати змішування"), + ("Knockout Group", "Група вибивання"), + ("Object:", "Об’єкт:"), + ("Group:", "Група:"), + ("Sample Color (Eyedropper)", "Узяти колір (піпетка)"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b..c9346437 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -178,6 +178,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&pts); pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); } + // Properties › Transform: rotate a quarter turn (an arc over a square), flip (a shape and its + // mirror image across a dashed axis). + "rotate-cw" | "rotate-ccw" => { + let m = |x: f32| if name == "rotate-ccw" { 20.0 - x } else { x }; + pen.rect(m(3.0).min(m(11.0)), 9.0, m(3.0).max(m(11.0)), 17.0); + let pts: Vec<(f32, f32)> = (0..12) + .map(|i| { + let a = std::f32::consts::PI * (1.0 - i as f32 / 11.0); + (m(11.0 + 5.0 * a.cos()), 9.0 - 5.0 * a.sin()) + }) + .collect(); + pen.line(&pts); + pen.line(&[(m(14.0), 7.0), (m(16.0), 9.5), (m(18.0), 7.0)]); + } + "flip-h" | "flip-v" => { + let t = |x: f32, y: f32| if name == "flip-v" { (y, x) } else { (x, y) }; + pen.fill(&[t(3.0, 16.0), t(8.5, 4.0), t(8.5, 16.0)]); + pen.closed(&[t(11.5, 16.0), t(11.5, 4.0), t(17.0, 16.0)]); + for k in 0..4 { + let y0 = 2.5 + k as f32 * 4.0; + pen.line(&[t(10.0, y0), t(10.0, y0 + 2.0)]); + } + } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { let a = 30 + i * 35; @@ -495,6 +518,38 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Pathfinder: a back square (top left) and a front one (bottom right), the result filled. + "pf-add" | "pf-subtract" | "pf-intersect" | "pf-exclude" | "pf-minus-back" => { + let (back, front) = ((3.0, 3.0, 13.0, 13.0), (7.0, 7.0, 17.0, 17.0)); + match name { + "pf-add" => { + pen.frect(back.0, back.1, back.2, back.3); + pen.frect(front.0, front.1, front.2, front.3); + } + "pf-subtract" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.rect(front.0, front.1, front.2, front.3); + } + "pf-intersect" => { + pen.rect(back.0, back.1, back.2, back.3); + pen.rect(front.0, front.1, front.2, front.3); + pen.frect(7.0, 7.0, 13.0, 13.0); + } + "pf-exclude" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + pen.rect(7.0, 7.0, 13.0, 13.0); + } + _ => { + pen.rect(back.0, back.1, back.2, back.3); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + } + } + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c3..fc7a3e63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,7 +16,9 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod panel_docking; pub mod panels; +mod prefs; pub mod render_worker; mod rtl; pub mod story_editor; @@ -97,6 +99,11 @@ pub struct SavedWorkspace { pub dock_expanded: bool, pub open_panel: Option, pub floating: Vec<(String, [f32; 2])>, + /// Custom panel membership and placement; absent in older workspace files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docking: Option>, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub docking_hidden: std::collections::BTreeMap>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -217,6 +224,20 @@ pub struct UiState { pub open_panel: Option, /// Panels torn off the dock into their own floating windows: (panel id, top-left position). pub floating: Vec<(String, [f32; 2])>, + /// Shared panel layout after the user customizes the legacy dock. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docking: Option>, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub docking_hidden: std::collections::BTreeMap>, + /// Invalidates in-flight gestures when a workspace is replaced. + #[serde(skip)] + pub docking_generation: u64, + /// One-frame reveal request; z-order is never persisted. + #[serde(skip)] + pub docking_raise: Option, + /// Last explicitly revealed customized panel, for the legacy closePanel control. + #[serde(skip)] + pub docking_opened: Option, pub dock_expanded: bool, pub units: Unit, pub workspace: String, @@ -266,6 +287,10 @@ pub struct UiState { pub palette: Option, #[serde(skip)] pub flyout: Option, + /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the + /// Color panel act on it (X toggles). + #[serde(skip)] + pub stroke_front: bool, } impl Default for UiState { @@ -310,6 +335,11 @@ impl Default for UiState { dock_tab: "properties".into(), open_panel: None, floating: Vec::new(), + docking: None, + docking_hidden: Default::default(), + docking_generation: 0, + docking_raise: None, + docking_opened: None, dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), @@ -336,6 +366,7 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + stroke_front: false, } } } @@ -484,6 +515,18 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// Quit was chosen: close the window on the next frame, as its close button does. + quit_requested: bool, + /// Close the window without asking about unsaved changes (the control channel's `app.quit`). + pub(crate) quit_without_asking: bool, + /// No document is discarded until every unsaved document has an up-to-date approval. + quitting: Option, +} + +struct QuitState { + original_active: Option, + accepted: HashMap, + pending: Option, } impl DesignApp { @@ -521,6 +564,9 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + quit_requested: false, + quit_without_asking: false, + quitting: None, } } @@ -776,6 +822,151 @@ impl DesignApp { let _ = self.open_dropped(&p); } } + self.handle_quit(ctx); + } + + /// Quit: close the window the way its close button does, so unsaved documents ask first. + pub fn request_quit(&mut self) { + self.quit_requested = true; + } + + /// Ask about unsaved documents without closing any of them yet. A later Cancel must also + /// keep documents whose earlier answer was Don't Save, including their history and recovery. + fn handle_quit(&mut self, ctx: &egui::Context) { + if std::mem::take(&mut self.quit_requested) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if ctx.input(|i| i.viewport().close_requested()) { + if self.quit_without_asking { + self.quitting = None; + return; + } + if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if approved { + // Commit only when the backend receives the final close, after checking for + // edits that arrived since the last answer. Closing also discards recovery data. + if let Err(e) = self.finish_confirmed_quit() { + self.status(e); + self.cancel_quit(); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + // Dock Quit can arrive while the app is hidden or minimized. Make its question + // visible through the normal safe window commands before asking about documents. + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if self.quitting.is_none() { + self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + } + } + let Some(q) = self.quitting.as_ref() else { return }; + if let Some(uid) = q.pending { + let asking = self.ui.dialog.as_ref().is_some_and(|d| { + d.id == "closeDocument" + && d.fields.get("quitting").and_then(Value::as_bool) == Some(true) + && d.fields.get("uid").and_then(Value::as_u64) == Some(uid) + }); + if !asking { + self.cancel_quit(); + } + return; + } + if self.ui.dialog.is_some() { + self.cancel_quit(); + return; + } + self.ask_next_unsaved(ctx); + } + + /// Finalize a complete, still-current quit plan at an orderly host exit. The host can + /// terminate before egui receives its queued Close; an unfinished/stale plan leaves every + /// document live so the host can preserve its recovery data instead. + pub fn finish_confirmed_quit(&mut self) -> Result<(), String> { + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if !approved { + return Ok(()); + } + while !self.session.documents().is_empty() { + self.run("file.close", json!({"index": 0}))?; + } + self.quitting = None; + Ok(()) + } + + fn next_unapproved_document(&self) -> Option<(usize, u64)> { + let q = self.quitting.as_ref()?; + self.session + .documents() + .iter() + .enumerate() + .find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision)) + .map(|(i, d)| (i, d.uid)) + } + + fn cancel_quit(&mut self) { + let Some(q) = self.quitting.take() else { return }; + if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) { + let _ = self.run("file.activate", json!({"index": i})); + } + } + + /// Save/Don't Save during Quit approves this UID's current revision, without destroying it. + pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result { + if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) { + return Err("Quit confirmation is no longer active".into()); + } + let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else { + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + } + return Ok(Value::Null); + }; + if !discard { + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Err(e) = saved { + self.cancel_quit(); + return Err(e); + } + if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { + // No file chosen (or an asynchronous host save has not completed): stop quitting. + self.cancel_quit(); + return Ok(Value::Null); + } + } + if let Some(q) = self.quitting.as_mut() { + if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { + q.accepted.insert(uid, d.revision); + } + q.pending = None; + } + Ok(Value::Null) + } + + /// Show the next unapproved document, or request a final close that revalidates all approvals. + fn ask_next_unsaved(&mut self, ctx: &egui::Context) { + let Some((i, uid)) = self.next_unapproved_document() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + if self.run("file.activate", json!({"index": i})).is_err() { + self.cancel_quit(); + return; + } + menus::close_document(self, Some(i)); + if let Some(d) = self.ui.dialog.as_mut() { + d.fields.insert("quitting".into(), json!(true)); + } + if let Some(q) = self.quitting.as_mut() { + q.pending = Some(uid); + } } /// A file dropped on the window: documents open, anything else is placed. @@ -786,6 +977,7 @@ impl DesignApp { /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file /// that can't be opened or placed gets an alert with the reason, as well as the status line. + /// A successful import with warnings keeps the document and shows the warnings in an alert. pub fn open_file(&mut self, cmd: &str, params: Value) -> Result { let file = match params.get("path").and_then(Value::as_str) { Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()), @@ -795,6 +987,20 @@ impl DesignApp { if let Err(e) = &r { let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); + } else if let Ok(result) = &r { + let warnings: Vec<&str> = result + .get("warnings") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|warning| !warning.is_empty()) + .collect(); + if !warnings.is_empty() { + self.status(format!("{file}: {}", warnings.join("; "))); + self.ui.dialog = + Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")}))); + } } r } @@ -824,6 +1030,9 @@ impl DesignApp { let t0 = now_ms(); let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; + if presenting || self.ui.hidden_panels != 0 { + panel_docking::cancel_gesture(self, &ctx); + } let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); if dbg { eprintln!("root start {:?}", ui.available_rect_before_wrap()); @@ -855,16 +1064,29 @@ impl DesignApp { if !presenting { chrome::doc_tabs(self, ui); } - if self.split && !presenting { + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - let r = ui.available_rect_before_wrap(); - let mid = r.center().x; - let halves = [ - egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)), - egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max), - ]; - ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border); - for (k, half) in halves.into_iter().enumerate() { + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { self.switch_pane(k as u8); // A new pane starts at the other pane's view. if k == 1 && self.view().is_none() { @@ -877,9 +1099,6 @@ impl DesignApp { } } ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) { - self.focus_pane = k as u8; - } } self.switch_pane(self.focus_pane); } else { @@ -1119,6 +1338,425 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// One frame of the quit handling, with the window asked to close when `close`; returns the + /// commands sent to the window. + fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn dialog_uid(app: &DesignApp) -> Option { + app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64()) + } + + /// Quitting closed the window at once, losing unsaved changes (#544). + #[test] + fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let cmds = quit_frame(&mut app, &ctx, true); + assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}"); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + // Cancel: the window and the document stay as they were. + app.ui.dialog = None; + for _ in 0..2 { + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + } + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); + // A Save whose file picker is cancelled stops quitting too. + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.session.documents()[0].is_dirty()); + } + + #[test] + fn quitting_asks_about_each_unsaved_document_then_closes() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap(); + app.run("file.new", json!({})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + for (n, uid) in uids.iter().take(2).enumerate() { + assert_eq!(dialog_uid(&app), Some(*uid), "question {n}"); + assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown"); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let cmds = quit_frame(&mut app, &ctx, false); + assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}"); + } + // All documents stay open until the backend's final close commits the decision. + assert_eq!(app.session.documents().len(), 3); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + } + + /// A later Cancel must not destroy documents already approved for discard. + #[test] + fn quitting_later_cancel_retains_every_document_and_active_tab() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + let active = app.session.active().unwrap().uid; + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + quit_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + assert_eq!(app.session.active().unwrap().uid, active); + assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::>(), revisions); + for (d, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&d.doc, &original)); + assert!(d.is_dirty()); + assert!(!d.history.undo.is_empty()); + } + } + + fn second_unsaved_document(app: &mut DesignApp) { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + + fn discard_quit_dialog(app: &mut DesignApp) { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(app).unwrap(); + } + + #[test] + fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let commands = quit_frame(&mut app, &ctx, true); + assert!(commands.contains(&egui::ViewportCommand::CancelClose)); + for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] { + assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}"); + } + discard_quit_dialog(&mut app); + let approved = quit_frame(&mut app, &ctx, false); + assert!(approved.contains(&egui::ViewportCommand::Close)); + let completed = quit_frame(&mut app, &ctx, true); + assert!(app.session.documents().is_empty()); + + let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + clean.run("file.new", json!({})).unwrap(); + let clean_close = quit_frame(&mut clean, &ctx, true); + let mut control = app_with_unsaved_document(); + control.quit_without_asking = true; + let control_close = quit_frame(&mut control, &ctx, true); + for commands in [&approved, &completed, &clean_close, &control_close] { + assert!( + !commands.iter().any(|command| matches!( + command, + egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus + )), + "completed and immediate quits must not activate the window: {commands:?}" + ); + } + } + + /// Dock Quit may arrive again while the first request's dialogs are still open. + #[test] + fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let original_active = app.session.active().unwrap().uid; + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let directory = tempfile::tempdir().unwrap(); + app.session.recovery_dir = Some(directory.path().to_path_buf()); + app.run("file.recovery.save", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + for _ in 0..3 { + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + } + app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + for (document, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&document.doc, &original)); + assert!(document.is_dirty()); + assert!(!document.history.undo.is_empty()); + assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists()); + } + // A fresh Dock Quit must ask about the first document again after cancellation. + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + } + + #[test] + fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("saved.designcraft"); + let chosen = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.session.recovery_dir = Some(directory.path().join("recovery")); + app.run("file.recovery.save", json!({})).unwrap(); + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(uids[0])); + dialogs::confirm(&mut app).unwrap(); // Save the first document. + assert!(!app.session.documents()[0].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists()); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + assert!(path.exists()); + assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0); + assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0); + } + + #[test] + fn quitting_first_cancel_restores_original_clean_active_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + let original_active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + assert_ne!(app.session.active().unwrap().uid, original_active); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_save_as_cancel_after_discard_keeps_all_documents() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + assert_eq!(app.session.active().unwrap().uid, active); + } + + #[test] + fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let path = std::env::temp_dir() + .join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid)) + .join("missing") + .join("document.designcraft"); + assert!(!path.parent().unwrap().exists()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + // Starting over must ask about the previously discarded first document again. + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + } + + #[test] + fn quitting_save_then_later_cancel_keeps_saved_document_open() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(app.session.documents().len(), 2); + assert!(!app.session.documents()[0].is_dirty()); + assert!(path.exists()); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents()[0].uid, first); + assert!(app.session.documents()[1].is_dirty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_rechecks_discard_approval_after_a_later_edit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + app.run("file.activate", json!({"index": 0})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision"); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_final_close_rechecks_new_unsaved_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + second_unsaved_document(&mut app); // Arrives after Close was queued, before its event. + let new_uid = app.session.active().unwrap().uid; + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(new_uid)); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + app.run("file.activate", json!({"index": 1})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); + assert!(!app.session.documents()[0].is_dirty()); + assert!(app.session.documents()[1].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_discard_preserves_recovery_until_final_commit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id())); + app.session.recovery_dir = Some(dir.clone()); + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(entry.exists(), "a queued close is not a committed close"); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart"); + assert!(app.session.documents().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn quitting_native_exit_commits_completed_approvals_before_close_event() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 1); + app.finish_confirmed_quit().unwrap(); + assert!(app.session.documents().is_empty()); + } + + #[test] + fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() { + for case in ["incomplete", "stale", "new document"] { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + if case == "incomplete" { + second_unsaved_document(&mut app); + } + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new document" { + second_unsaved_document(&mut app); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + app.finish_confirmed_quit().unwrap(); + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + } + } + } + + #[test] + fn quitting_without_unsaved_changes_closes_at_once() { + let ctx = egui::Context::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + // Quit (⌘Q) closes the window like its close button. + app.run("app.quit", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + + /// Scripts must not hang on a question: the control channel's quit never asks. + #[test] + fn control_channel_quit_does_not_ask() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let (req, _rx) = control::ControlRequest::new("app.quit", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + control::handle(&mut app, ui.ctx(), &req); + }); + out.textures_delta.clear(); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + /// The Control panel's two-row groups were centred as if they were one row tall, so the second /// row hung below the panel and was cut off. #[test] @@ -1280,6 +1918,99 @@ mod tests { } } +#[cfg(test)] +mod import_warning_tests { + use super::*; + use std::io::Write; + + fn idml(missing_parts: bool) -> Vec { + let missing = if missing_parts { r#""# } else { "" }; + let map = format!( + r#"{missing}"# + ); + let spread = r#""#; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, text) in [("mimetype", designcraft_idml::MIMETYPE), ("designmap.xml", &map), ("Spreads/one.xml", spread)] { + zip.start_file(name, options).unwrap(); + zip.write_all(text.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() + } + + fn app() -> DesignApp { + let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() }); + app.run("file.new", json!({"title": "Existing work"})).unwrap(); + app.run("frame.create", json!({"rect": [10, 10, 50, 50]})).unwrap(); + app + } + + fn assert_partial_import_warning(app: &mut DesignApp, file: &str) { + let dialog = app.ui.dialog.as_ref().expect("partial import must show an alert"); + assert_eq!(dialog.id, "alert"); + assert_eq!(dialog.fields["title"], "Import Warnings"); + assert_eq!(dialog.fields["file"], file); + for part in ["Spreads/missing.xml", "Stories/missing.xml"] { + assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named"); + assert!(app.ui.status.contains(part), "the status line retains the warning"); + } + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.documents()[0].doc.title, "Existing work"); + assert!(app.session.documents()[0].is_dirty(), "existing unsaved work stays intact"); + assert_eq!(app.session.documents()[0].doc.spreads[0].items.len(), 1); + assert_eq!(app.session.doc().unwrap().doc.spreads.len(), 1); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "available document content is kept"); + dialogs::confirm(app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK dismisses the warning"); + assert_eq!(app.session.documents().len(), 2, "dismissing the warning keeps the imported document"); + } + + #[test] + fn byte_open_reports_partial_import_warnings_without_losing_documents() { + let mut app = app(); + let result = + app.open_file("file.openBytes", json!({"name": "partial.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(true))})).unwrap(); + assert_eq!(result["warnings"].as_array().unwrap().len(), 2); + assert_partial_import_warning(&mut app, "partial.idml"); + } + + #[test] + fn native_open_reports_partial_import_warnings_without_losing_documents() { + let path = std::env::temp_dir().join(format!("designcraft-partial-import-{}", std::process::id())).join("partial.idml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, idml(true)).unwrap(); + let picked = path.to_string_lossy().to_string(); + let mut app = app(); + app.services.pick_open = Some(Box::new(move |_| Some(picked.clone()))); + let result = app.run("app.openDialog", json!({})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(path.parent().unwrap()).unwrap(); + assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2); + assert_partial_import_warning(&mut app, "partial.idml"); + } + + #[test] + fn inbox_open_reports_partial_import_warnings_without_losing_documents() { + let mut app = app(); + let request = app.import_request("open"); + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) }); + app.drain_inbox(); + assert_partial_import_warning(&mut app, "partial"); + } + + #[test] + fn complete_import_does_not_show_a_warning() { + let mut app = app(); + let result = app + .open_file("file.openBytes", json!({"name": "complete.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(false))})) + .unwrap(); + assert_eq!(result["warnings"], json!([])); + assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.is_empty()); + assert_eq!(app.session.documents().len(), 2); + } +} + /// The whole window in a headless test harness, at a chosen window size. #[cfg(test)] pub(crate) mod test_window { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aa21af36..31694c79 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), ("app.save", "Save", Some("Cmd+S"), "{}"), + ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), @@ -86,6 +87,20 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), + ( + "color.defaultFillStroke", + "Default Fill and Stroke", + Some("D"), + "{} — the selection gets no fill and a black stroke (object.defaultFillStroke)", + ), + ( + "color.swapFillStroke", + "Swap Fill and Stroke", + Some("Shift+X"), + "{} — each selected object's fill and stroke colours (and tints) trade places (object.swapFillStroke)", + ), + ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), + ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -131,6 +146,22 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", ), ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ( + "window.panel.layout", + "Change Panel Layout", + None, + "{action} — serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + None, + "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + ), + ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), + ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), + ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), + ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), @@ -388,34 +419,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.sizeDown", "-", "ui:view.hiddenCharacters", - "ui:view.taggedFrames", - ">Rotate Spread", - "ui:view.rotateSpread|90° CW|{\"angle\": 90}", - "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", - "ui:view.rotateSpread|180°|{\"angle\": 180}", - "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", - "<", - ">Proof Setup", - "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", - "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", - "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", - "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", - "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", - "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", - "-", - "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", - "<", - "ui:view.proofColors", - "ui:view.flattenerPreview", - "ui:view.tagMarkers", - ">Separations Preview", - "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", - "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", - "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", - "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", - "ui:view.separations|Black|{\"plate\": \"black\"}", - "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", - "<", "-", ">Hyperlinks & Cross-References", "cmd:hyperlink.create", @@ -471,6 +474,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", + "-", + "cmd:path.reverse", "<", ">Pathfinder", "cmd:object.pathfinder|Add|{\"op\": \"add\"}", @@ -605,6 +610,28 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "View", &[ + "ui:view.overprintPreview", + ">Proof Setup", + "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", + "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", + "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", + "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", + "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", + "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", + "-", + "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", + "<", + "ui:view.proofColors", + ">Separations Preview", + "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", + "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", + "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", + "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", + "ui:view.separations|Black|{\"plate\": \"black\"}", + "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", + "<", + "ui:view.flattenerPreview", + "-", "ui:view.zoomIn", "ui:view.zoomOut", "ui:view.fitPage", @@ -613,8 +640,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.actualSize", "ui:view.entirePasteboard", "-", - "ui:view.overprintPreview", - "ui:view.togglePreview", + ">Rotate Spread", + "ui:view.rotateSpread|90° CW|{\"angle\": 90}", + "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", + "ui:view.rotateSpread|180°|{\"angle\": 180}", + "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", + "<", + "-", + ">Screen Mode", + "ui:view.screenMode|Normal|{\"mode\": \"normal\"}", + "ui:view.screenMode|Preview|{\"mode\": \"preview\"}", + "ui:view.screenMode|Bleed|{\"mode\": \"bleed\"}", + "ui:view.screenMode|Slug|{\"mode\": \"slug\"}", + "ui:view.screenMode|Presentation|{\"mode\": \"presentation\"}", + "<", ">Display Performance", "ui:view.fastDisplay", "ui:view.typicalDisplay", @@ -622,10 +661,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:view.rulers", + "-", + ">Extras", "ui:view.frameEdges", "ui:view.textThreads", - "ui:view.hiddenCharacters", - "-", + "<", ">Grids & Guides", "ui:view.guides", "ui:view.snapToGuides", @@ -635,6 +675,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:app.deleteAllGuides", "<", + "-", + ">Structure", + "ui:view.taggedFrames", + "ui:view.tagMarkers", + "<", ], ), ( @@ -709,6 +754,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } let rect = app.canvas_rect; let flag = |b: &mut bool| { *b = !*b; @@ -752,6 +800,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.request_quit(); + Ok(Value::Null) + } "app.saveCopyDialog" => { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { @@ -1093,6 +1145,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::toolbar::default_fill_stroke(app), + "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), + "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { app.canvas.shown = None; @@ -1382,6 +1438,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Option { + app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1); + app.ui.docking_raise = None; + app.ui.docking_opened = None; let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() { app.ui.control_bar = w.control_bar; @@ -1415,10 +1476,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("properties"), @@ -1748,6 +1816,17 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.proofColors" => app.ui.proof_colors, "view.flattenerPreview" => app.ui.flattener_preview, "view.tagMarkers" => app.ui.tag_markers, + "view.screenMode" => { + let mode = match params.get("mode").and_then(Value::as_str) { + Some("normal") => crate::ScreenMode::Normal, + Some("preview") => crate::ScreenMode::Preview, + Some("bleed") => crate::ScreenMode::Bleed, + Some("slug") => crate::ScreenMode::Slug, + Some("presentation") => crate::ScreenMode::Presentation, + _ => return None, + }; + app.ui.screen_mode == mode + } "view.rotateSpread" => match params.get("angle").and_then(Value::as_i64) { Some(0) => app.session.active().is_some_and(|st| st.doc.spreads.iter().all(|sp| sp.pages.first().is_none_or(|p| p.view_rotation == 0))), _ => return None, @@ -2069,6 +2148,7 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { "," => key = Some(egui::Key::Comma), "." => key = Some(egui::Key::Period), "\\" => key = Some(egui::Key::Backslash), + "/" => key = Some(egui::Key::Slash), "Delete" => key = Some(egui::Key::Delete), k => key = egui::Key::from_name(k), } @@ -2246,6 +2326,64 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str) -> mod tests { use super::*; + /// The entries (command ids) of a menu bar menu, submenus included. + fn menu_ids(title: &str) -> Vec<&'static str> { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() + } + + #[test] + fn view_items_are_in_the_view_menu_as_in_indesign() { + let (view, typ) = (menu_ids("View"), menu_ids("Type")); + for id in [ + "view.rotateSpread", + "view.proofSetup", + "view.proofColors", + "view.flattenerPreview", + "view.separations", + "view.taggedFrames", + "view.tagMarkers", + ] { + assert!(view.contains(&id), "{id} is in View"); + assert!(!typ.contains(&id), "{id} isn't in Type"); + } + // Screen Mode and Extras are View submenus; Show Hidden Characters stays in Type. + assert!(view.contains(&"view.screenMode") && view.contains(&"view.frameEdges")); + assert!(typ.contains(&"view.hiddenCharacters") && !view.contains(&"view.hiddenCharacters")); + // W still toggles Normal / Preview without a menu entry. + assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); + } + + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. + fn submenu_paths(title: &str) -> Vec { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + let mut stack = vec![title.to_string()]; + let mut out = Vec::new(); + for e in items { + if let Some(name) = e.strip_prefix('>') { + let path = format!("{}/{name}", stack.last().map_or("", String::as_str)); + out.push(path.clone()); + stack.push(path); + } else if *e == "<" { + stack.pop(); + } + } + out + } + + #[test] + fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); + app.ui.screen_mode = crate::ScreenMode::Preview; + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); + // Every View submenu has its Japanese path (Edit › Menus shows them). + for path in submenu_paths("View") { + assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); + } + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { @@ -2443,6 +2581,170 @@ mod tests { frame(&mut app); } + #[test] + fn split_window_dispatches_text_once_to_the_shared_document() { + for focus_pane in [0, 1] { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let made = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let story = made["story"].as_u64().unwrap(); + app.session.set_tool("type"); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + assert!(app.session.wants_text()); + app.run("window.split", json!({"on": true})).unwrap(); + app.focus_pane = focus_pane; + + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let undo_before = app.session.active().unwrap().history.undo.len(); + frame(&mut app, vec![egui::Event::Text("A".into())]); + let text = app.run("story.get", json!({"story": story})).unwrap(); + assert_eq!(text["text"], "A", "one key press with pane {focus_pane} focused"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], ""); + + // A press and key arriving together must use the newly focused pane, whichever + // canvas is drawn first. Middle-click changes pane focus without moving the caret. + let other = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + frame(&mut app, vec![egui::Event::PointerMoved(other)]); + frame( + &mut app, + vec![ + egui::Event::PointerButton { pos: other, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("B".into()), + ], + ); + assert_eq!(app.focus_pane, 1 - focus_pane); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "B", "a key in the pane-switch frame is dispatched once"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + } + } + + #[test] + fn split_window_click_focus_routes_menu_zoom_but_hover_scroll_stays_local() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut crate::DesignApp, events| { + time += 0.1; + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let left = app.canvas_rect.unwrap().center(); + let right = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + let cameras = |app: &mut crate::DesignApp| { + let current = app.pane; + app.switch_pane(0); + let left = *app.view().unwrap(); + app.switch_pane(1); + let right = *app.view().unwrap(); + app.switch_pane(current); + [left, right] + }; + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![egui::Event::PointerMoved(right)]); + frame(&mut app, vec![button(right, true)]); + frame(&mut app, vec![button(right, false)]); + assert_eq!((app.pane, app.focus_pane), (1, 1)); + let before = cameras(&mut app); + app.run("view.zoom", json!({"zoom": 2.0})).unwrap(); + let zoomed = cameras(&mut app); + assert_eq!(zoomed[0], before[0], "menu zoom leaves the other camera alone"); + assert!((zoomed[1].zoom - 2.0).abs() < 1e-9); + + frame(&mut app, vec![egui::Event::PointerMoved(left)]); + frame( + &mut app, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }], + ); + let scrolled = cameras(&mut app); + assert_eq!(scrolled[1], zoomed[1], "hover scroll leaves the focused camera alone"); + assert_ne!(scrolled[0].origin, zoomed[0].origin); + assert_eq!((app.pane, app.focus_pane), (1, 1), "hovering does not move menu focus"); + + frame(&mut app, vec![button(left, true)]); + frame(&mut app, vec![button(left, false)]); + assert_eq!((app.pane, app.focus_pane), (0, 0)); + app.run("view.zoom", json!({"zoom": 1.0})).unwrap(); + let final_views = cameras(&mut app); + assert!((final_views[0].zoom - 1.0).abs() < 1e-9); + assert_eq!(final_views[1], scrolled[1]); + } + + #[test] + fn split_window_contextual_bars_have_independent_widget_ids() { + fn id_warnings(shape: &egui::Shape, found: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| id_warnings(shape, found)), + egui::Shape::Text(text) if text.galley.text().contains("use of widget ID") => found.push(text.galley.text().to_string()), + _ => {} + } + } + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for rect in [[36, 36, 280, 180], [300, 36, 550, 180]] { + app.run("frame.create", json!({"rect": rect, "content": "unassigned"})).unwrap(); + } + app.run("edit.selectAll", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + ctx.options_mut(|options| options.warn_on_id_clash = true); + let mut warnings = Vec::new(); + for _ in 0..4 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + warnings.clear(); + for shape in &output.shapes { + id_warnings(&shape.shape, &mut warnings); + } + output.textures_delta.clear(); + } + assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}"); + let bars: Vec<_> = + (0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect(); + assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); + } + #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs new file mode 100644 index 00000000..05fc1150 --- /dev/null +++ b/crates/ui-egui/src/panel_docking.rs @@ -0,0 +1,519 @@ +//! Application-owned panel IDs and commands around the shared docking layout. + +use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use serde_json::{Value, json}; + +use crate::DesignApp; + +#[derive(Clone, Default)] +struct LegacyDrag { + layout: Option>, + open_panel: Option, + floating: Vec<(String, [f32; 2])>, + generation: u64, +} + +pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { + cancel_legacy_drag(app, ctx); + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); +} + +fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { + let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); + let previous = ctx.data_mut(|data| data.remove_temp::(key)); + if let Some(previous) = previous { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + if previous.generation != app.ui.docking_generation { + return false; + } + app.ui.open_panel = previous.open_panel; + app.ui.floating = previous.floating; + app.ui.docking = previous.layout; + true + } else { + false + } +} + +fn normalize(raw: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS + .iter() + .chain(crate::dock::ICON_PANELS) + .find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())) + .map(|(id, _, _)| *id) +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +} + +fn legacy(app: &DesignApp) -> Layout { + let panels: Vec = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); + let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); + let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; + for (id, at) in &app.ui.floating { + if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { + layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); + } + } + if let Some(id) = &app.ui.open_panel + && normalize(id).is_some() + && !layout.contains(id) + { + // Preserve the visible flyout when the first unrelated docking gesture succeeds. + if let Some(Node::Tabs { panels, active }) = &mut layout.root { + panels.push(id.clone()); + *active = panels.len().saturating_sub(1); + } + } + layout +} + +fn first_root(layout: &Layout, except: &str) -> Option { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(|p| p.as_str() != except).cloned() +} + +fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { + let anchor = first_root(layout, panel); + layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) +} + +fn number(params: &Value, key: &str, default: f32) -> Result { + match params.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|v| v.is_finite() && v.abs() <= 1_000_000.0) + .map(|v| v as f32) + .ok_or_else(|| format!("{key} must be a finite coordinate")), + } +} + +fn placement(params: &Value) -> Result, String> { + if let Some(before) = params.get("before") { + let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; + return Ok(Placement::Tab { before: Some(id.into()) }); + } + let zone = match params.get("zone") { + None => "tab", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + Ok(match zone { + "tab" => Placement::Tab { before: None }, + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be tab, center, left, right, top or bottom".into()), + }) +} + +pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option> { + if id == "app.tablePanel" && app.ui.docking.is_some() { + let panel = "table".to_string(); + let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); + return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + } + if id == "window.panel.layout" { + return Some( + params + .get("action") + .ok_or_else(|| "action is required".to_string()) + .and_then(|value| serde_json::from_value::>(value.clone()).map_err(|error| error.to_string())) + .and_then(|action| apply_action(app, action)), + ); + } + let operation = match id { + "window.panel.move" => "move", + "window.panel.float" | "window.floatPanel" => "float", + "window.panel.dock" | "window.dockPanel" => "dock", + "window.panel.activate" => "activate", + "window.panel.close" => "close", + "window.panel" if app.ui.docking.is_some() => "activate", + _ => return None, + }; + Some(change(app, operation, params)) +} + +// UI output and automation share this atomic dispatcher. The application owns the registry +// and saved return locations; craft-ui owns tree validation and structural changes. +fn apply_action(app: &mut DesignApp, action: Action) -> Result { + let registered = |id: &String| normalize(id) == Some(id.as_str()); + let known = match &action { + Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { + registered(panel) + && registered(anchor) + && match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + } + } + Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), + Action::Float { panel, .. } + | Action::Close { panel } + | Action::Activate { panel } + | Action::MoveFloating { panel, .. } + | Action::SetStackOpen { panel, .. } + | Action::ResizeStack { panel, .. } => registered(panel), + Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, + }; + if !known { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let returning = match &action { + Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)), + Action::Float { panel, .. } => { + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + } + _ => None, + }; + let reveal = match &action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }; + layout.apply(action).map_err(|error| error.to_string())?; + if let Some((panel, location)) = returning { + app.ui.docking_hidden.insert(panel, location); + } + app.ui.docking = Some(layout); + sync_legacy(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.dock_expanded = true; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + app.ui.open_panel = None; + Ok(json!({"layout": app.ui.docking})) +} + +fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut hidden: std::collections::BTreeMap<_, _> = + app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); + let mut restored = false; + if operation == "dock" + && params.get("anchor").or_else(|| params.get("onto")).is_none() + && let Some(location) = hidden.get(panel).filter(|location| { + location.floating.is_none() + && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) + }) + { + let mut candidate = layout.clone(); + if candidate.contains(&panel.to_string()) { + candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + if candidate.restore(panel.into(), location).is_ok() { + layout = candidate; + restored = true; + hidden.remove(panel); + } + } + match operation { + "dock" if restored => {} + "float" => { + let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; + ensure(&mut layout, panel)?; + if let Ok(location) = layout.location(&panel.to_string()) + && location.floating.is_none() + { + hidden.insert(panel.into(), location); + } + layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?; + } + "move" | "dock" => { + if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { + return Err("move requires a destination panel".into()); + } + let anchor = match params.get("anchor").or_else(|| params.get("onto")) { + Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), + None => first_root(&layout, panel), + }; + let placement = placement(params)?; + if let Some(anchor) = anchor { + ensure(&mut layout, panel)?; + layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + } else if operation == "dock" { + if layout.contains(&panel.to_string()) { + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + } else { + return Err("move requires an existing destination panel".into()); + } + } + "close" => { + hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + _ => { + if !layout.contains(&panel.to_string()) + && let Some(location) = hidden.get(panel) + { + // A missing neighbor is allowed: the normal visible dock becomes the fallback. + let _ = layout.restore(panel.into(), location); + } + ensure(&mut layout, panel)?; + hidden.remove(panel); + } + } + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + sync_legacy(app); + app.ui.open_panel = None; + app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; + app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; + if operation != "close" { + app.ui.dock_expanded = true; + } + Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::>(), "layout": app.ui.docking})) +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { + let area = egui::Id::new("designcraft-panel-docking"); + let changed_workspace = ui.ctx().data_mut(|data| { + let previous = data.get_temp::(area.with("generation")); + data.insert_temp(area.with("generation"), app.ui.docking_generation); + previous.is_some_and(|generation| generation != app.ui.docking_generation) + }); + if changed_workspace { + craft_ui::docking::cancel_drag::(ui.ctx(), area); + ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); + } + + let cancelled = + ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); + if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { + return false; + } + let Some(layout) = app.ui.docking.as_ref() else { return false }; + if !valid(layout) { + app.ui.docking = None; + app.status("Invalid panel layout; restored the default dock"); + return false; + } + let mut layout = layout.clone(); + if !app.ui.dock_expanded { + layout.root = None; + } + let raise = app.ui.docking_raise.take(); + let mut blocked = Vec::new(); + if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { + blocked.push(ui.ctx().content_rect()); + } else if app.story_editor.is_some() { + // The editor is rendered after the dock; block its unmeasured first frame conservatively. + let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); + blocked.push(rect); + } + let t = crate::theme::Tokens::get(ui.ctx()); + let language = app.ui.language.clone(); + let mut draw = |ui: &mut egui::Ui| { + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned(); + style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); + style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); + style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); + style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); + style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); + let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); + if let Some(panel) = &raise { + area = area.raise_panel(panel.clone()); + } + area.show_customized( + ui, + &layout, + &style, + |id| { + crate::i18n::tr( + &language, + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), + ) + .to_owned() + }, + |_| craft_ui::docking::Permissions::default(), + |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, + &mut PanelContent(app), + ) + }; + let output = if layout.root.is_some() { + egui::Panel::right("shared-panel-dock") + .default_size(310.0) + .size_range(220.0..=720.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, &mut draw) + .inner + } else { + draw(ui) + }; + if let Some(panel) = output.focused { + app.ui.docking_opened = Some(panel); + } + if let Some(error) = output.error { + app.ui.status = error.to_string(); + } + if ui.input(|input| input.pointer.any_released()) { + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + return app.ui.docking.is_some(); + } + ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); + } + for action in output.actions { + if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + app.ui.status = error; + } + } + icon_rail(app, ui); + true +} + +fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let column = egui::Panel::right("shared-panel-icons") + .exact_size(38.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if app.ui.docking.as_ref().is_some_and(|layout| { + layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) + }) { + continue; + } + + let response = + crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); + if response.clicked() { + let _ = app.run("window.panel.activate", json!({"panel":id})); + } + legacy_tab(app, ui, id, &response); + } + }); + }) + .response + .rect; + let _ = column; +} + +pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { + let Some(panel) = normalize(panel) else { return }; + response.context_menu(|ui| panel_menu(app, ui, panel)); + if response.drag_started() + && let Some(id) = normalize(panel) + { + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let bounds = ui.max_rect(); + let source = egui::Rect::from_min_size( + egui::pos2(bounds.left(), response.rect.top()), + egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), + ); + if ensure(&mut layout, id).is_ok() + && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source) + { + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("designcraft-panel-docking").with("legacy-origin"), + LegacyDrag { + layout: app.ui.docking.clone(), + open_panel: app.ui.open_panel.clone(), + floating: app.ui.floating.clone(), + generation: app.ui.docking_generation, + }, + ) + }); + app.ui.docking = Some(layout); + app.ui.open_panel = None; + } + } +} + +/// The docking commands of a panel: its tab's context menu and the end of its panel menu. +pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.status(error); + } + ui.close(); + } + } + }); + }); +} + +struct PanelContent<'a>(&'a mut DesignApp); +impl craft_ui::docking::DockContent for PanelContent<'_> { + fn body(&mut self, ui: &mut egui::Ui, id: &String) { + egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt(("shared-panel-body", id)) + .auto_shrink([false, false]) + .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + }); + } + fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } + fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } +} + +/// Close the last explicitly opened panel through the same validated layout transaction. +pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { + if app.ui.docking.is_some() { + if let Some(panel) = app.ui.docking_opened.clone() { + change(app, "close", &json!({"panel": panel}))?; + } + } else { + app.ui.open_panel = None; + } + Ok(()) +} + +// Legacy inspection fields are derived from the shared layout after customization. +fn sync_legacy(app: &mut DesignApp) { + if let Some(layout) = &app.ui.docking { + app.ui.floating = + layout.floating.iter().flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))).collect(); + } +} + +#[cfg(test)] +#[path = "panel_docking_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs new file mode 100644 index 00000000..de08e3cf --- /dev/null +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -0,0 +1,624 @@ +use super::*; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), Default::default()) +} + +#[test] +fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); + let before_float = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"])); + app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into())); + app.run("window.panel.activate", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(valid(layout)); + let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect(); + assert_eq!(unique.len(), layout.panels().len()); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.docking, app.ui.docking); + assert_eq!(restored.docking_hidden, app.ui.docking_hidden); +} + +#[test] +fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { + let mut app = app(); + for (id, params) in [ + ("window.panel.move", json!({"panel":"layers","anchor":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})), + ("window.panel.float", json!({"panel":"layers","width":-10})), + ("window.panel.float", json!({"panel":"unknown"})), + ("window.panel.float", json!({"panel":"layers","x":"invalid"})), + ] { + let old = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!(app.ui.docking, old); + assert_eq!(app.ui.docking_hidden, hidden); + } +} + +fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { + let mut ready = false; + let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |_| {}); + }, + app, + ); + harness.run_steps(4); + harness +} + +fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + drag(&mut h, source, egui::pos2(160.0, 140.0)); + let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); + assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, source, destination); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); + assert!(valid(layout)); +} + +#[test] +fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { + let mut app = app(); + + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap(); + app.run("window.panel.close", json!({"panel":"swatches"})).unwrap(); + let layout = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + app.run("window.newWorkspace", json!({"name":"Custom docking"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + assert!(app.ui.docking.is_none()); + app.run("window.workspace", json!({"name":"Custom docking"})).unwrap(); + assert_eq!(app.ui.docking, layout); + assert_eq!(app.ui.docking_hidden, hidden); +} + +/// Explicit offscreen evidence; this creates no native windows. +#[test] +fn capture_custom_panel_docking_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for theme in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap(); + let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" }; + app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap(); + let mut ready = false; + let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Custom panel workspace"); + if app.ui.language == "ar" { + ui.label("Shaped action references:"); + for action in ["Float Panel", "Group with", "Close Panel"] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); + } + } + }); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(5); + assert!(valid(harness.state().ui.docking.as_ref().unwrap())); + harness + .render() + .unwrap() + .save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) + .unwrap(); + if language == "ar" { + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string())); + let at = harness.ctx.read_response(tab).unwrap().rect.center(); + harness.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + harness.run_steps(1); + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Secondary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(2); + assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); + for action in ["Float Panel", "Group with", "Close Panel"] { + assert!( + harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, + "both logical reference and actual menu label must exist" + ); + } + harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); + } + } + } + } +} + +#[test] +fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert!(h.state().ui.docking.is_some()); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().ui.docking.is_none()); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.docking.is_none()); +} + +#[test] +fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap(); + assert!( + matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001) + ); + app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); + assert_eq!( + app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect, + [44.0, 66.0, 360.0, 450.0] + ); + app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) + .unwrap(); + assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]); + let before = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + for action in [ + json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}), + json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}), + json!({"Open":{"panel":"unknown","anchor":null}}), + json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}), + ] { + assert!(app.run("window.panel.layout", json!({"action":action})).is_err()); + assert_eq!(app.ui.docking, before); + assert_eq!(app.ui.docking_hidden, hidden); + } + app.ui.docking = Some(Layout { + root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + floating: Vec::new(), + }); + app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); + let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") }; + assert!(!entries[0].open); + assert_eq!(entries[0].height, Some(150.0)); +} + +fn group_members_for_test(layout: &Layout, panel: &str) -> Vec { + let mut pending: Vec<_> = layout.root.iter().collect(); + while let Some(node) = pending.pop() { + match node { + Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]), + _ => {} + } + } + Vec::new() +} + +#[test] +fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"weight":8})).unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap(); + let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from); + let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); + let builder = if directory.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + let at = h.get_by_label("Bevel").rect().center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); + } + let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, from, to); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + h.render().unwrap().save(directory.join("designcraft-populated-stroke-redocked.png")).unwrap(); + } +} + +#[test] +fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let destination = app.ui.docking.clone(); + app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().ui.docking, destination); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, destination); +} + +#[test] +fn first_customization_preserves_the_visible_legacy_flyout() { + let mut app = app(); + app.ui.open_panel = Some("paragraphStyles".into()); + app.ui.dock_expanded = false; + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.contains(&"paragraphStyles".into())); + assert!(valid(layout)); + assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1); +} + +#[test] +fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); + let destination = h.state().ui.docking.clone(); + let open_panel = h.state().ui.open_panel.clone(); + let floating = h.state().ui.floating.clone(); + // The app calls this before dock rendering when panels are hidden or Presentation starts. + let ctx = h.ctx.clone(); + cancel_gesture(h.state_mut(), &ctx); + assert_eq!(h.state().ui.docking, destination); + assert_eq!(h.state().ui.open_panel, open_panel); + assert_eq!(h.state().ui.floating, floating); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, destination); +} + +#[test] +fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false})); + let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") }; + assert_eq!(result["ok"], true); + assert!(!app.ui.dock_expanded); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("window.panel", json!({"panel":"properties"})).unwrap(); + assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); +} + +#[test] +fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); + assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none()); + app.run("window.workspace", json!({"name":"Typography"})).unwrap(); + assert!(app.ui.docking_raise.is_none()); +} + +#[test] +fn shaped_panel_label_keeps_the_logical_accessibility_text() { + let ctx = egui::Context::default(); + let logical = "خصائص Pages"; + crate::theme::install_fonts(&ctx, "ar"); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE); + assert_eq!(galley.job.text, logical); + }); + output.textures_delta.clear(); +} + +#[test] +fn modal_overlay_blocks_a_real_tab_drag_release() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let area = egui::Id::new("designcraft-panel-docking"); + let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture"); + let before = h.state().ui.docking.clone(); + h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + let destination = egui::pos2(150.0, 150.0); + h.event(Event::PointerMoved(destination)); + h.run_steps(1); + h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel"); + h.state_mut().ui.dialog = None; + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag"); +} + +#[test] +fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + for visible in [true, false] { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(1); + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers }); + h.run_steps(2); + assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible); + } + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before); +} + +#[test] +fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"] + .as_u64() + .unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor"); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none()); + let layout = h.state().app.ui.docking.clone(); + let frame_before = h.ctx.cumulative_frame_nr(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + // The unmeasured first frame blocks the full content, even outside the eventual editor. + let release = egui::pos2(150.0, 150.0); + h.input_mut().events.extend([ + Event::PointerMoved(release), + Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame"); + let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds"); + assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); + h.run_steps(3); + let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + let screen = h.ctx.content_rect(); + let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)] + .into_iter() + .find(|p| !editor.contains(*p)) + .expect("fixture has space outside editor"); + let before_outside = h.state().app.ui.docking.clone(); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab)); + h.event(Event::PointerMoved(outside)); + h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds"); + let after = h.state().app.ui.docking.clone(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); +} + +#[test] +fn capture_legacy_panel_menu_visual_fixture() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); + app.run("app.language", json!({"lang":"ar"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Shaped legacy menu references:"); + for label in ["Float Panel", "Layers"] { + crate::rtl::label(ui, crate::i18n::tr("ar", label)); + } + }); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); + let at = h.get_by_label("Properties").rect().center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); + let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); + h.hover_at(group); + h.run_steps(4); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); + assert!(h.state().ui.docking.is_none()); + let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); + let logical = crate::i18n::tr("ar", last_label); + let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); + let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); + let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); + let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); + assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); + h.input_mut().events.extend([ + Event::PointerMoved(scroll_at), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + h.run_steps(12); + let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); + assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); + assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); + h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); + h.run_steps(4); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); + let group = group_members_for_test(layout, last); + assert!(group.iter().any(|panel| panel == "properties")); + assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); + assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); + h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); +} + +#[test] +fn legacy_dock_menu_button_opens_the_active_panel_menu() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.ui.dock_tab = "pages".into(); + let pages = app.session.active().unwrap().doc.page_count(); + let mut h = harness(app, vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let at = egui::pos2(dock.right() - 15.0, tab.center().y); + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); + h.get_by_label("Insert Page After").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); + assert!(!egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 05a73395..581f6747 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -251,8 +251,9 @@ struct FontMenuState { /// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard /// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites /// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move -/// through the matches and Return picks one. Returns the family picked and closes the menu; the -/// search, Favorites and the stars keep it open (the popup closes on a click outside only). +/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog +/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the +/// stars keep it open (the popup closes on a click outside only). pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option { let (fonts, scope) = (fonts(app), font_scope(app)); let english = app.session.prefs.show_font_names_in_english; @@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if search.changed() { st.highlight = if q.is_empty() { None } else { Some(0) }; } - let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter))); + // The menu owns these keys while it is open: a dialog under it must not take Return as OK or + // Escape as Cancel. + let (down, up, enter, escape) = ui.input_mut(|i| { + let mut key = |k| i.consume_key(egui::Modifiers::NONE, k); + (key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape)) + }); let last = shown.len().checked_sub(1); st.highlight = match (st.highlight, last) { (_, None) => None, @@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if favs_changed { let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); } - if pick.is_some() { + if pick.is_some() || escape { ui.close(); } pick @@ -404,6 +410,12 @@ pub fn font_combo( /// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family /// picked. pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { + // Screen readers (and tests) see the field as the combo box it looks like. + field.widget_info(|| { + let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); + info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }); + info + }); egui::Popup::menu(field) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) @@ -506,9 +518,22 @@ mod font_menu_ui_tests { /// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the /// story. fn typing() -> (Harness<'static, Window>, u64) { + typing_in("Advanced") + } + + /// [`typing`] in the workspace `name`. + fn typing_in(name: &str) -> (Harness<'static, Window>, u64) { + typing_on(name, vec2(1440.0, 900.0), false) + } + + /// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the + /// title bar inside the window) when `mac`. + fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.ui.control_bar = true; - let mut h = test_window::open(app, vec2(1440.0, 900.0)); + app.run("window.workspace", json!({ "name": name })).unwrap(); + app.native_menu = mac; + app.integrated_titlebar = mac; + let mut h = test_window::open(app, size); let app = &mut h.state_mut().app; let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap(); app.run("tool.select", json!({"tool": "type"})).unwrap(); @@ -523,7 +548,7 @@ mod font_menu_ui_tests { /// The Control bar's font field, showing `family`. fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect { - h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect() + fields(h, family)[0] } /// The text in the focused search field. @@ -582,4 +607,94 @@ mod font_menu_ui_tests { assert_eq!(story_text(&mut h, story), before); assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected"); } + + /// The font fields showing `family`, left to right and top to bottom. + fn fields(h: &Harness<'static, Window>, family: &str) -> Vec { + let mut r: Vec = + h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect(); + r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y))); + r + } + + /// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu + /// stays open and lists only the matches, then picks the first with ↓ and Return. + fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) { + click_at(h, at.center()); + let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect()); + let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused")); + click_at(h, field.center()); + assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open"); + h.event(egui::Event::Text("SOURCE SA".into())); + h.run_steps(4); + assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search"); + assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed"); + assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed"); + } + + /// Every font field over selected text (Control bar, Contextual Task Bar, Properties › + /// Character, Character panel) searches, in the workspaces that show them and in a small + /// window with the macOS window chrome. + #[test] + fn every_font_field_searches_in_every_workspace() { + for (name, size, mac, panel) in [ + ("Advanced", vec2(1440.0, 900.0), false, None), + ("Printing and Proofing", vec2(1440.0, 900.0), false, None), + ("Essentials", vec2(1440.0, 900.0), false, Some("character")), + ("Printing and Proofing", vec2(900.0, 560.0), true, None), + ] { + let (mut h, story) = typing_on(name, size, mac); + if panel.is_some() { + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.run_steps(4); + } + let name = &format!("{name} {size:?} {panel:?}"); + let before = story_text(&mut h, story); + let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len(); + assert_eq!(count, 3, "{name}: the font fields are shown"); + for k in 0..count { + h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); + h.run_steps(4); + let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; + search_in_menu(&mut h, at, &format!("{name}, field {k}")); + assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); + assert_eq!(story_text(&mut h, story), before, "{name}, field {k}"); + } + } + } + + /// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic + /// Character Formats and Find/Replace Font's Replace With. + #[test] + fn font_fields_in_panels_and_dialogs_search() { + let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"}); + for (what, panel, dialog, shown) in [ + ("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY), + ("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY), + ("Find/Replace Font", None, Some(("findFont", json!({}))), "—"), + ] { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string()); + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields)); + h.run_steps(4); + let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown")); + search_in_menu(&mut h, at, what); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open"); + assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked"); + // Escape closes the menu only. + let at = fields(&h, "Source Sans 3")[0]; + click_at(&mut h, at.center()); + assert!(h.ctx.text_edit_focused(), "{what}: the menu opened"); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu"); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open"); + } + } } diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad906..ae323225 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -216,6 +216,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The panel menu's page commands, acting on the current page and its spread. +pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let Some((si, _)) = doc.page_loc(cur) else { return }; + page_menu(app, ui, &doc, cur, si); +} + fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f..d9c74e40 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + if i.count > 1 { + section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); + pathfinder_row(app, ui); + divider(ui); + } if i.is_text && i.count == 1 { section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); text_frame_section(app, ui); @@ -460,11 +465,62 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { set.insert("ref".into(), json!(rp)); let _ = app.run("transform.set", Value::Object(set)); } + transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("info".into()); } } +/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the +/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. +fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tv = app.session.execute("transform.info", &json!({})).ok(); + let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 4.0); + let mut set = serde_json::Map::new(); + let rows = [ + [("tool-rotate", "Rotation Angle", "rotation", "°", "p_rot"), ("tool-scale", "Scale X Percentage", "scaleX", "%", "p_sx")], + [("tool-shear", "Shear X Angle", "shear", "°", "p_shear"), ("tool-scale", "Scale Y Percentage", "scaleY", "%", "p_sy")], + ]; + for (row, cells) in rows.into_iter().enumerate() { + let yy = row as f32 * 25.0; + for (col, (icon, tip, key, suffix, id)) in cells.into_iter().enumerate() { + let x = if col == 0 { 40.0 } else { 120.0 }; + let ir = sub(b, x, yy + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { + set.insert(key.into(), json!(v)); + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, tip, cmd, params) in [ + ("rotate-cw", "Rotate 90° Clockwise", "transform.rotate", json!({"angle": -90})), + ("rotate-ccw", "Rotate 90° Counterclockwise", "transform.rotate", json!({"angle": 90})), + ("flip-h", "Flip Horizontal", "transform.flip", json!({"axis": "horizontal"})), + ("flip-v", "Flip Vertical", "transform.flip", json!({"axis": "vertical"})), + ] { + let label = crate::i18n::tr(&lang, tip); + let resp = icon_toggle(ui, icon, false, label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if resp.clicked() { + let _ = app.run(cmd, params); + } + } + }); +} + /// A stroke-type preview dropdown (light box with the line style). fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ @@ -1724,6 +1780,27 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. +const PATHFINDER: [(&str, &str, &str); 5] = [ + ("pf-add", "add", "Add"), + ("pf-subtract", "subtract", "Subtract"), + ("pf-intersect", "intersect", "Intersect"), + ("pf-exclude", "exclude", "Exclude Overlap"), + ("pf-minus-back", "minusBack", "Minus Back"), +]; + +/// Properties › Pathfinder (several objects selected): combine them into one shape. +fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.pathfinder", json!({"op": op})); + } + } + }); +} + /// Pathfinder panel: combine the selected shapes; make or release compound paths. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong()); @@ -1988,6 +2065,13 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.opacity", json!({"opacity": (v / 100.0).clamp(0.0, 1.0)})); } }); + // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). + let group = it.is_group(); + ui.horizontal(|ui| { + let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); + crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); + crate::rtl::label(ui, format!("{} {}%", crate::i18n::tr(&app.ui.language, it.blend.label()), (it.opacity * 100.0).round())); + }); divider(ui); let ds = it.effects.drop_shadow.clone(); let mut on = ds.on; @@ -2030,6 +2114,20 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); } + divider(ui); + // Groups only, as in InDesign (greyed for other objects). + ui.horizontal(|ui| { + let mut iso = it.isolate; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Isolate Blending")); + if ui.add_enabled(group, egui::Checkbox::new(&mut iso, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"isolate": iso})); + } + let mut ko = it.knockout; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Knockout Group")); + if ui.add_enabled(group, egui::Checkbox::new(&mut ko, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"knockout": ko})); + } + }); } pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -2207,4 +2305,178 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + #[test] + fn transform_flips_and_rotates_from_properties() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let xf = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().xf; + // Flip Horizontal mirrors the frame (a negative determinant); Flip Vertical as well brings it + // back to a half turn, as in the tutorial's petal. + h.get_by_label("Flip Horizontal").click(); + h.run_steps(3); + assert!(xf(&h).determinant() < 0.0, "{:?}", xf(&h)); + h.get_by_label("Flip Vertical").click(); + h.run_steps(3); + let c = xf(&h).as_coeffs(); + assert!((c[0] + 1.0).abs() < 1e-9 && (c[3] + 1.0).abs() < 1e-9, "a half turn: {c:?}"); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap().abs() - 180.0).abs() < 1e-6, "{info}"); + // A quarter turn clockwise from there. + h.get_by_label("Rotate 90° Clockwise").click(); + h.run_steps(3); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap() - 90.0).abs() < 1e-6, "{info}"); + // The angle and scale fields are there. + assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); + } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } +} + +#[cfg(test)] +mod effects_panel_tests { + use super::*; + + /// One frame of the Effects panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| effects_panel(app, ui)); + out.textures_delta.clear(); + let mut v = Vec::new(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + v.push((t.galley.job.text.clone(), t.visual_bounding_rect())); + } + } + v + } + + fn click(app: &mut DesignApp, ctx: &egui::Context, label: &str) { + let labels = frame(app, ctx, vec![]); + let pos = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} is painted")).1.center(); + for pressed in [true, false] { + frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + #[test] + fn the_effects_panel_shows_the_level_and_isolate_blending_and_knockout_group_for_groups() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let g = app.run("object.group", json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [g]})).unwrap(); + app.run("object.opacity", json!({"opacity": 1.0, "blend": "colorDodge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for expected in ["Group:", "Color Dodge 100%", "Isolate Blending", "Knockout Group"] { + assert!(texts.iter().any(|t| t == expected), "{expected} in {texts:?}"); + } + let item = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap(); + click(&mut app, &ctx, "Isolate Blending"); + assert!(item(&app, g).isolate); + click(&mut app, &ctx, "Knockout Group"); + assert!(item(&app, g).knockout); + // Another object: the level is Object, and the group options are greyed. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "{texts:?}"); + click(&mut app, &ctx, "Knockout Group"); + assert!(!item(&app, a).knockout); + } + + #[test] + fn pasted_into_frames_keep_object_level_and_disable_group_blending() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let child = app.run("frame.create", json!({"rect": [80, 80, 120, 120]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [child]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let container = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + app.run("edit.pasteInto", json!({})).unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "a frame remains an object after Paste Into: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click(&mut app, &ctx, "Isolate Blending"); + click(&mut app, &ctx, "Knockout Group"); + let st = app.session.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(container)).unwrap(); + assert!(it.has_nested_items()); + assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); + assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a59664..d64cc1bd 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -150,3 +150,44 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn character_style_plus_captures_selection_for_reuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "alpha beta"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.char", json!({"underline": true})).unwrap(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + character(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Create New Style from Selection").click(); + h.run_steps(3); + let app = h.state_mut(); + let style = app.session.doc().unwrap().doc.styles.char_style("Character Style 1").unwrap(); + assert_eq!(style.chars, designcraft_doc::CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + app.run("text.select", json!({"story": frame["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "Character Style 1"})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["underline"], true); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1aa11ddb..7544347a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -209,9 +209,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { return; }; let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let stroke_id = egui::Id::new("color_panel_stroke"); let mode_id = egui::Id::new("color_panel_mode"); - let mut stroke: bool = ui.data(|d| d.get_temp(stroke_id)).unwrap_or(false); + let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; @@ -253,10 +252,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - ui.data_mut(|d| { - d.insert_temp(stroke_id, stroke); - d.insert_temp(mode_id, mode); - }); + app.ui.stroke_front = stroke; + ui.data_mut(|d| d.insert_temp(mode_id, mode)); let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs new file mode 100644 index 00000000..cb5a0b54 --- /dev/null +++ b/crates/ui-egui/src/prefs.rs @@ -0,0 +1,116 @@ +//! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. + +use craft_ui::docking::{Layout, Location}; +use serde_json::Value; + +use crate::UiState; + +impl UiState { + /// Read saved preferences, discarding only unreadable optional docking metadata. + /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. + pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result { + let mut saved: Value = serde_json::from_slice(bytes)?; + recover_docking(&mut saved); + if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { + for workspace in workspaces { + recover_docking(workspace); + } + } + serde_json::from_value(saved) + } +} + +fn recover_docking(saved: &mut Value) { + let Some(fields) = saved.as_object_mut() else { return }; + if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { + fields.remove("docking"); + } + if let Some(hidden) = fields.get_mut("dockingHidden") { + if let Some(entries) = hidden.as_object_mut() { + entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); + } else { + fields.remove("dockingHidden"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DesignApp, SavedWorkspace}; + use serde_json::json; + + fn populated() -> UiState { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + app.ui.ui_scale = 1.5; + app.ui.language = "ja".into(); + app.ui.custom_workspaces = vec![ + SavedWorkspace { + name: "Print".into(), + docking: app.ui.docking.clone(), + docking_hidden: app.ui.docking_hidden.clone(), + ..Default::default() + }, + SavedWorkspace { name: "Proof".into(), docking: app.ui.docking.clone(), ..Default::default() }, + ]; + app.ui + } + + #[test] + fn saved_preferences_preserve_valid_layouts_and_hidden_origins() { + let original = serde_json::to_value(populated()).unwrap(); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(loaded).unwrap(), original); + } + + #[test] + fn future_layouts_recover_independently_without_resetting_preferences_or_workspaces() { + let original = populated(); + for field in ["docking", "dockingHidden"] { + for invalid in [json!({"root":{"FutureLayout":{}}}), json!(false)] { + let mut saved = serde_json::to_value(&original).unwrap(); + saved[field] = invalid.clone(); + saved["customWorkspaces"][0][field] = invalid; + assert!(serde_json::from_value::(saved.clone()).is_err(), "command parsing remains strict"); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert_eq!(loaded.ui_scale, 1.5); + assert_eq!(loaded.language, "ja"); + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Print"); + assert_eq!(loaded.custom_workspaces[1].name, "Proof"); + assert_eq!(loaded.custom_workspaces[1].docking, original.custom_workspaces[1].docking); + if field == "docking" { + assert!(loaded.docking.is_none()); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } else { + assert!(loaded.docking_hidden.is_empty()); + assert!(loaded.custom_workspaces[0].docking_hidden.is_empty()); + assert_eq!(loaded.docking, original.docking); + assert_eq!(loaded.custom_workspaces[0].docking, original.custom_workspaces[0].docking); + } + } + } + } + + #[test] + fn unreadable_hidden_origin_does_not_discard_other_origins() { + let original = populated(); + let mut saved = serde_json::to_value(&original).unwrap(); + saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); + saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!original.docking_hidden.is_empty()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } + + #[test] + fn unrelated_malformed_preferences_are_still_rejected() { + assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); + assert!(UiState::from_preferences_json(br#"{"customWorkspaces":false}"#).is_err()); + } +} diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae2..a554bc64 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -76,6 +76,14 @@ pub fn plain(ctx: &egui::Context, text: &str, font: FontId, color: Color32) -> A layout(ctx, LayoutJob::simple_singleline(text.to_owned(), font, color)) } +/// One line no wider than `max_width`: longer text ends in an ellipsis, cut at a grapheme +/// boundary in logical order (`Galley::elided` tells whether it was cut). +pub fn truncated(ctx: &egui::Context, text: &str, font: FontId, color: Color32, max_width: f32) -> Arc { + let mut job = LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_width.max(0.0)); + layout(ctx, job) +} + pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: FontId, color: Color32) { let galley = plain(p.ctx(), text, font, color); let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 9b25afd4..cf66e9d5 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip"; /// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside /// `canvas`. pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { - let grip_id = egui::Id::new(GRIP); + let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane)); // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) let dragging = ctx.dragged_id() == Some(grip_id); if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) { @@ -28,7 +28,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { return; } let t = Tokens::get(ctx); - let id = egui::Id::new("task_bar"); + // The same selection can be shown in two canvases; each bar needs its own area and widgets. + let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane)); // Width from the previous frame (content-sized). let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); let at = match app.ui.task_bar_pin { @@ -46,7 +47,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { pos2(x, y) } }; - app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + // A shared persisted pin is relative to each canvas. The transient pin target belongs + // to the focused pane, so rendering the sibling cannot overwrite it. + if app.pane == app.focus_pane { + app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + } egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) @@ -75,7 +80,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { } else { object_controls(app, ui); } - more(app, ui); + more(app, ui, [at.x - canvas.min.x, at.y - canvas.min.y]); }); }); }); @@ -266,7 +271,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { } } -fn more(app: &mut DesignApp, ui: &mut Ui) { +fn more(app: &mut DesignApp, ui: &mut Ui, at: [f32; 2]) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click()); if resp.hovered() { @@ -286,7 +291,8 @@ fn more(app: &mut DesignApp, ui: &mut Ui) { let pinned = app.ui.task_bar_pin.is_some(); let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position"); if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() { - let _ = app.run("window.taskBarPin", json!({"on": !pinned})); + let params = if pinned { json!({"on": false}) } else { json!({"on": true, "at": at}) }; + let _ = app.run("window.taskBarPin", params); ui.close(); } if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() { @@ -323,7 +329,7 @@ mod tests { } fn bar(h: &Harness<'static, Window>) -> egui::Rect { - h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap() } fn grip(h: &Harness<'static, Window>) -> egui::Pos2 { @@ -372,6 +378,59 @@ mod tests { assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0])); } + #[test] + fn split_pane_grips_handle_only_their_own_drag() { + let (mut h, _) = window(); + h.set_size(vec2(2200.0, 900.0)); + // A compact object HUD fits in each pane, leaving room to exercise both axes + // without the intentional canvas-edge clamp masking horizontal movement. + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + let area = + |h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap(); + for pane in [0_u8, 1] { + h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap(); + h.run_steps(4); + let before = area(&h, pane); + let from = pos2(before.min.x + 10.0, before.center().y); + let delta = vec2(24.0, 28.0); + drag(&mut h, from, delta); + let after = area(&h, pane); + assert!((after.min - before.min - delta).length() < 2.0, "pane {pane} drag was handled by a sibling grip: {before:?} -> {after:?}"); + assert!(h.state().app.ui.task_bar_pin.is_some()); + } + } + + #[test] + fn popup_pin_uses_the_invoking_hud_position_after_focus_moves_to_sibling() { + let (mut h, _) = window(); + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + h.state_mut().app.switch_pane(1); + h.state_mut().app.run("view.zoom", json!({"zoom":0.5})).unwrap(); + h.state_mut().app.switch_pane(0); + h.run_steps(4); + let expected = h.state().app.ui.task_bar_at.unwrap(); + let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap(); + click_at(&mut h, pos2(left.max.x - 19.0, left.center().y)); + let pin = h.get_by_label_contains("Pin Bar Position").rect(); + let at = pos2(pin.max.x - 2.0, pin.center().y); + let sibling = h.state().app.other_pane.as_ref().and_then(|(_, rect)| *rect).unwrap(); + assert!(sibling.contains(at), "fixture popup must extend into sibling canvas: {at:?} outside {sibling:?}"); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().app.focus_pane, 1); + assert_ne!(h.state().app.ui.task_bar_at, Some(expected), "fixture HUD positions must differ"); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().app.ui.task_bar_pin, Some(expected), "Pin must use the invoking HUD position"); + } + #[test] fn the_grip_moves_the_bar_and_it_stays_there() { let (mut h, id) = window(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd50180..193abf34 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -332,18 +332,11 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { v.selection.stroke = Stroke::new(1.0, t.text_strong); v.hyperlink_color = t.accent; v.override_text_color = Some(t.text); - for (w, fill) in [ - (&mut v.widgets.noninteractive, t.panel), - (&mut v.widgets.inactive, t.button), - (&mut v.widgets.hovered, t.hover), - (&mut v.widgets.active, t.tool_active), - (&mut v.widgets.open, t.hover), - ] { - w.bg_fill = fill; - w.weak_bg_fill = fill; - w.corner_radius = r; - w.fg_stroke = Stroke::new(1.0, t.text); - } + craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to( + &mut v.widgets, + r, + Stroke::new(1.0, t.text), + ); v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08..20f49475 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -196,9 +196,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); let info = crate::panels::sel_info(app); let doc = app.session.active().map(|d| d.doc.clone()); - let stroke_front_id = egui::Id::new("proxy_stroke_front"); let text_id = egui::Id::new("proxy_text"); - let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + let stroke_front = app.ui.stroke_front; let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); let (fill_sw, stroke_sw) = match &info { Some(i) => (i.fill.clone(), i.stroke.clone()), @@ -216,8 +215,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": "[None]"})); - let _ = app.run("object.stroke", json!({"swatch": "[Black]"})); + let _ = app.run("color.defaultFillStroke", json!({})); } let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); icons::paint(ui.painter(), swap, "swap", t.icon); @@ -228,8 +226,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": stroke_sw})); - let _ = app.run("object.stroke", json!({"swatch": fill_sw})); + let _ = app.run("color.swapFillStroke", json!({})); } ui.add_space(2.0); // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. @@ -272,16 +269,31 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { crate::rtl::label(ui, fill_tip); }); fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); + let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)"); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); + crate::rtl::label(ui, stroke_tip); }); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip)); if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; - ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + app.ui.stroke_front = front; } + // A double-click opens the Color Picker for that box (as InDesign's), with the selection's + // colour: the selected text's when text is selected. if fresp.double_clicked() || sresp.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "swatches"})); + let on_fill = fresp.double_clicked() && (!stroke_front || !sresp.double_clicked()); + let stroke = !on_fill; + let text = crate::panels::text_attrs(app).filter(|_| app.session.active().is_some_and(|d| d.selection.text.is_some())); + let current = match text { + Some(a) => a["chars"][if stroke { "stroke" } else { "fill" }].as_str().unwrap_or("[None]").to_string(), + None if stroke => stroke_sw.clone(), + None => fill_sw.clone(), + }; + let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex}))); } ui.add_space(3.0); // Formatting affects container / text. @@ -318,7 +330,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { flyout_triangle(ui.painter(), well, t.icon); let target = if stroke_front { "object.stroke" } else { "object.fill" }; if resp.clicked() { - let _ = app.run(target, json!({"swatch": "[None]"})); + let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { @@ -411,6 +423,28 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: }) } +/// Default Fill and Stroke (D): no fill, a black stroke. +pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { + app.run("object.defaultFillStroke", json!({})) +} + +/// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. +pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { + app.run("object.swapFillStroke", json!({})) +} + +/// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. +pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { + app.ui.stroke_front = !app.ui.stroke_front; + Ok(json!({"strokeFront": app.ui.stroke_front})) +} + +/// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. +pub(crate) fn apply_none(app: &mut DesignApp) -> Result { + let target = if app.ui.stroke_front { "object.stroke" } else { "object.fill" }; + app.run(target, json!({"swatch": "[None]"})) +} + #[cfg(test)] mod tests { use egui::{pos2, vec2}; @@ -472,6 +506,36 @@ mod tests { assert!(!h.state().app.ui.tools_double_column); } + #[test] + fn double_clicking_the_fill_box_opens_the_color_picker_with_the_selections_colour() { + let mut app = app(false); + app.run("file.new", serde_json::json!({})).unwrap(); + let id = app.run("frame.create", serde_json::json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + app.run("object.color", serde_json::json!({"color": "#456daf", "ids": [id]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [id]})).unwrap(); + // Frames 1 ms apart, so two clicks make a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut crate::DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label(FILL).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("the Color Picker opened"); + assert_eq!(d.id, "colorPicker"); + assert_eq!((d.fields["target"].as_str(), d.fields["hex"].as_str()), (Some("fill"), Some("#456daf")), "{:?}", d.fields); + } + #[test] fn a_tall_window_keeps_one_column_and_does_not_scroll() { let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); @@ -482,4 +546,42 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. + #[test] + fn fill_stroke_proxy_keys() { + use egui::{Event, Key, Modifiers}; + use serde_json::json; + fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::menus::shortcuts(app, ui.ctx())).textures_delta.clear(); + } + fn colors(app: &crate::DesignApp) -> (String, f32, String, f32) { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint) + } + let mut app = app(false); + let ctx = egui::Context::default(); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", json!({"ids": [f["id"]]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})).unwrap(); + + press(&mut app, &ctx, Key::X, Modifiers::SHIFT); + assert_eq!(colors(&app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps, tints included"); + press(&mut app, &ctx, Key::D, Modifiers::NONE); + assert_eq!(colors(&app), ("[None]".into(), 1.0, "[Black]".into(), 1.0), "D: no fill, black stroke"); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X brings the stroke to the front"); + press(&mut app, &ctx, Key::Slash, Modifiers::NONE); + assert_eq!(colors(&app).2, "[None]", "/ applies None to the stroke in front"); + + // Typing in a story: the keys are text, not shortcuts. + app.session.execute("text.select", &json!({"story": f["story"], "anchor": 5})).unwrap(); + app.select_tool("type"); + assert!(app.session.wants_text()); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9..bcb27ddf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -408,8 +408,10 @@ pub fn selection_band(ui: &mut Ui, title: &str) { pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let t = Tokens::get(ui.ctx()); ui.add_space(7.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + // Screen readers hear the section's title. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title)); ui.add_space(3.0); true } @@ -525,6 +527,8 @@ pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egu } let s = (size.y - 4.0).min(17.0); crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); + // Named by its tooltip, for screen readers. + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); if tip.is_empty() { resp } else { diff --git a/docs/agents.md b/docs/agents.md index 0dd23f06..5115654a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,8 +21,12 @@ Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph ## Scripts: several commands, later ones using earlier results A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of -`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the -previous step); `"${N.path}"` interpolates inside longer strings. +`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value +of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous +step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` +interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +`designcraft-cli script --help` prints this reference. ```sh cat > page.dcs <<'DCS' diff --git a/docs/arabic-typography.md b/docs/arabic-typography.md index bff397d2..7bc6a98a 100644 --- a/docs/arabic-typography.md +++ b/docs/arabic-typography.md @@ -1,5 +1,7 @@ # Arabic import and composition +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Task: AR1. These changes repair verified gaps in the existing typesetting pipeline. They do not claim full InDesign or vendor-specific Naskh composition parity. @@ -74,3 +76,10 @@ compatibility warnings remain, along with separate font/link/overset findings. - [Diacritic position enumeration](https://developer.adobe.com/indesign/uxp/dom/api/d/diacritic-position-options/) and [positional forms](https://developer.adobe.com/indesign/uxp/dom/api/p/positional-forms/) identify serialized options; their presence alone does not establish visual parity. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-07 | major | Created: AR1: Arabic import and composition | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..39a9039d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,107 @@ +# DesignCraft architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 + +How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` +(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the +rules (never crash, everything is a command, layering, clean-room). + +## Workspace + +A Cargo workspace: 16 library crates in `crates/`, 3 apps in `apps/`, and `xtask`. 126,411 +lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default, +`unsafe_code = "forbid"` workspace-wide. + +| Layer | Crate | What it does | Lines | +|---|---|---|---| +| L0 | `geom` | kurbo re-export, units and measurement parsing (`1p6`, `12mm`, `3in+2p`), corner shapes, polygons, transforms, hit testing | 2.5k | +| L0 | `color` | CMYK/RGB/Gray (Lab in CMS), swatches, tints, gradients, blend modes, ASE, ICC colour management (`cms/`), colour harmony | 2.9k | +| L1 | `doc` | the document model: spreads, pages, parents, layers, items, stories (text + run lists), tables, notes, styles of five kinds, swatches, sections, variables, CJK and Arabic attributes | 10.2k | +| L1 | `fonts` | font database (bundled OFL fonts, system fonts, document fonts, optional craft-fonts), metrics, outlines (skrifa), shaping (harfrust), variable instances, font classification for menus | 3.5k | +| L2 | `compose` | the text engine: style resolution, shaping, Knuth–Plass and single-line breaking, Liang hyphenation, justification, tabs, keeps, columns, span/split, baseline grid, vertical justification, wrap, threading, overset, anchored objects, footnotes, tables, bidi, CJK (ruby, warichu, kinsoku), caret and hit testing | 11.7k | +| L2 | `images` | decoding placed graphics (PNG, JPEG, GIF, WebP, TIFF incl. CMYK, BMP, PSD composite, EPS preview) | 1.6k | +| L3 | `render` | document to pixels with vello_cpu (SIMD, multithreaded): items, composed text, images with mips, gradients, effects, damage regions, placed PDF via hayro | 3.5k | +| L3 | `pdf` | PDF export with krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | +| L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k | +| L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k | +| L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k | +| L3 | `format` | the native `.designcraft` zip (`document.json`, `assets/`, `meta.json`), versioned | 0.3k | +| L4 | `tools` | tool state machines: pointer events and modifiers to commands and overlays; snapping and smart guides | 5.9k | +| L5 | `engine` | `Session`: documents, history, selection, the command registry (446 commands in `cmd/`), compose cache, preflight, find/change, scripts, recovery, links | 29.3k | +| L6 | `ui-egui` | the InDesign-style egui frontend: menus, Control panel, Tools, dock and panels, dialogs, canvas, theme tokens, i18n, the control channel | 33.4k | +| L6 | `mcp` | MCP server over stdio, headless or proxying to the running app | 1.7k | +| L7 | `apps/designcraft` | desktop binary (eframe/wgpu), file dialogs, control server | 1.6k | +| L7 | `apps/designcraft-cli` | headless CLI: `run`, `script`, `app`, `describe`, `commands`, `mcp` | 1.2k | +| L7 | `apps/designcraft-web` | the same UI in WASM (WebGPU, WebGL2 fallback) | 0.3k | + +`cargo xtask layers` enforces the layering: a crate depends only on lower layers (with the +intra-layer orders geom → color and doc → fonts); nothing below L6 depends on egui, eframe, winit +or rfd; L0–L6 compile for `wasm32-unknown-unknown` (`cargo xtask wasm`). + +## Data model + +Coordinates are points (1/72 in), y down; each spread has its own coordinate system with its +origin at the top left of its first page. A `Document` holds settings, spreads and parent +spreads (items under `Arc`, so edits are copy-on-write and undo is cheap), layers, stories, +styles (paragraph, character, object, table, cell; based-on chains and groups), swatches, +sections and text variables. A story is one `String` plus paragraph and character run lists of +`{len, style, overrides}`; effective attributes are default ← based-on chain ← style ← overrides. +Special characters (page number, footnote reference, anchored object, table) are placeholder +code points in the text. Threading is the story's ordered frame list. + +## Commands, the only way to change a document + +Every user-visible action is a `CommandSpec` (`id`, `label`, menu path, shortcut, parameter +doc, `enabled`, `run`, journaled, undoable) in `crates/engine/src/cmd/*`. The UI, the CLI, MCP +and the control channel all call `Session::execute(id, params)`. UI-only commands (windows, +panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way. +Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled +command is replayable as a script. Escaped panics during a command or an import/export become an +error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically +into each running app's locked folder. Orderly native shutdown finalizes complete, current quit +approvals and writes the remaining documents' latest recovery state, including clean-document +cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned +winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests, +including Quit from the macOS Dock. The existing unsaved-document transaction can cancel +those requests or exit after current approvals are complete. A pending prompt restores and +focuses a hidden or minimized window; completed and immediate quits do not activate it. The +AppKit callback never runs the confirmation dialogs inside a modal termination loop. + +## Composition and rendering + +The compose cache is keyed by story revision, frame geometry, style revision and font database +revision; stories compose in parallel (rayon) on native targets. Composition produces per-frame +lines with positioned glyph runs, decorations, anchors and overset. The canvas renders on a +background worker with vello_cpu into tiles; after an edit only frames whose composition changed +are re-rendered and patched (damage regions, pixel-identical to a full render). The GPU (wgpu: +Metal, DirectX 12, Vulkan, OpenGL; WebGPU/WebGL2 on the web) only presents; the backend is chosen +before the window exists, with a start-up fallback list in `gpu.json`. + +## File I/O + +Native `.designcraft` (zip + JSON); IDML both ways; PDF, EPUB, HTML, PNG/JPEG, text, XML export; +placed graphics through `images` (PDF/AI pages through hayro); Word/RTF/Excel/Tagged Text through +`textimport`. INDD is not readable. Details: [file-format-parity.md](file-format-parity.md). + +## Agent control + +The desktop app opens a loopback JSON-lines control channel (`--control PORT`) with +`engine.execute`, inspection, real input injection (`ui.pointer`, `ui.key`, `ui.click`, `ui.drag`), +dialog control, screenshots and offscreen renders. `designcraft-cli mcp` exposes the same as MCP +tools, headless or connected to the app. See [agents.md](agents.md), [mcp.md](mcp.md), +[control-protocol.md](control-protocol.md). + +## Quality gates + +`cargo xtask ci`: rustfmt, clippy `-D warnings`, tests, `assets` (every asset attributed in +ASSETS.md), `layers`, `wasm`. Clean crates deny `unwrap`/`expect`/`panic!`/`todo!`/ +`unimplemented!`/`unreachable!`. CI on GitHub currently runs only the FreeBSD and Windows ARM64 +builds (on manifest changes), packaging lint and the release workflow; `xtask ci` runs locally. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | +| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | +| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/cjk-typography.md b/docs/cjk-typography.md index f85b14ed..49d350b8 100644 --- a/docs/cjk-typography.md +++ b/docs/cjk-typography.md @@ -1,5 +1,7 @@ # CJK import and composition +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Task: CJK1. This is incremental support, not a claim of InDesign composition parity. ## Data flow @@ -62,3 +64,10 @@ removing unsupported-rule warnings to produce a clean report is not acceptable. Tests use synthetic documents; user IDML files and proprietary application output are not committed as fixtures. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-07 | major | Created: CJK1: IDML CJK import and composition | diff --git a/docs/cjk.md b/docs/cjk.md index 5da0a74a..179b6f25 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -1,5 +1,7 @@ # CJK typesetting plan +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a DesignCraft document can be set to the standard of a Japanese, Chinese or Korean publisher, @@ -285,3 +287,11 @@ Each follows what an InDesign user expects. Chinese, since the exact names InDesign writes are unconfirmed without a sample file. 8. **Missing glyphs** are drawn as the font's box and reported, as in InDesign; drawing them from fallback fonts is a per-document choice, on only for documents saved before it existed. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-10 | minor | Updated (see git history) | +| 2026-10-06 | major | Created: CJK typesetting plan and status (the Japanese/CJK feature set) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d3b6f62a..9e514561 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,17 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before +launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset +override preserves the usual platform paths; an explicitly empty override disables those paths. +`DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery. + +Control pointer and key input is queued across native frames. After a drag, wait for both the +expected document state and `document.inspect.canUndo` before testing undo; an object can appear +in the preview before its undo entry is committed. On macOS, default shortcuts owned by the native +menu (including Command-Z) are skipped by the egui shortcut handler. `ui.key` does not send a Cocoa +menu event; use `ui.menu.invoke` for a menu-command test and test physical accelerators separately. + **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else gets one error reply (`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so @@ -35,7 +46,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | -| `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +| `app.open` / `app.save` / `app.export` | | Files | +| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` / `.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new @@ -44,3 +56,40 @@ merged document; the template stays as it was ([agents.md](agents.md#data-merge) Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu). The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. + +## Shared panel docking + +Panel arrangements support tab grouping and reordering, horizontal and vertical splits, +and movable, resizable floating groups inside the application window. Drag a panel tab, +or use its context menu. Floating groups remain within the application viewport. + +The shared layout begins with the existing workspace on the first docking operation. +Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the +positions of hidden panels. Resetting a built-in workspace restores its default dock. + +- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. + `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders + tabs by panel ID. The destination must already be visible. +- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. +- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous + dock placement when possible, or to the supplied destination. +- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved + position when possible. `window.panel.close {panel}` hides it. + +Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and +invalid destinations return an error without changing the arrangement. + +`window.panel.layout {action}` also accepts the shared, externally tagged action schema. +For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}` +updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}` +resizes the root divider (`false`/`true` path entries select first/second children). +`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and +`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries. +The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale +split paths return an error without changing the workspace. + +After panel customization, `app.tablePanel` (including Shift+F9) still toggles the +Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; +`dockExpanded:false` hides the root dock while floating groups remain visible. +The icon rail can reopen a collapsed dock through any root panel. Activating a +floating panel also raises its group. These operations change only UI layout. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 00000000..fc8fbc3a --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,110 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) + +Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The +InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, +`UTExportedTypeDeclarations`), its import/export filter folder names and its File menus; ours from +the open/place/export filters in `apps/designcraft/src/main.rs` and the `file.*` commands. +Overall numbers: [target-app-parity.md](target-app-parity.md); open problems: [gaps.md](gaps.md). + +**Dimension score: ~40% ready (estimated), 150–250 h + 20–60 h for INDD integration.** The +single largest gap is that `.indd` can't be opened. + +Read/Write: **✓** supported · **~** partial · **✗** missing · **—** InDesign doesn't either. +Weight: what share of an InDesign professional's file traffic the format carries (used for the +score below). + +## Documents and interchange + +| Format | Ext | InDesign | DesignCraft read | DesignCraft write | Fidelity / tests | Weight | +|---|---|---|---|---|---|---| +| InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | +| InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | +| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | +| InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | +| Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | +| InCopy story / assignment | `.icml`, `.icma`, `.incx`, `.inca` | read/write | ✗ | ✗ | InCopy workflow out of scope; ICML story import would still help | 1% | +| InDesign Interchange (legacy) | `.inx` | read | ✗ | — | | <1% | +| PageMaker | `.pmd`, `.p65`, `.t65`, `.pmt` | read | ✗ | — | | <1% | +| QuarkXPress | `.qxd`, `.qxt` | read (old versions) | ✗ (#73) | — | | <1% | + +## Output + +| Format | Ext | InDesign | DesignCraft | Fidelity / tests | Weight | +|---|---|---|---|---|---| +| PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF | 53 tests in `crates/pdf`; no PDF/X-1a or X-3 (needs flattening to PDF 1.3/1.4); no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% | +| PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% | +| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% | +| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, CSS from styles, images, nav | | 2% | +| EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% | +| HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% | +| HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% | +| SVG export | `.svg` | ✓ (via plug-in) | ✗ | | <1% | +| EPS export | `.eps` | ✓ | ✗ | | <1% | +| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% | +| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% | +| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% | +| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% | + +## Placed and imported content + +| Format | Ext | InDesign | DesignCraft | Notes | Weight | +|---|---|---|---|---|---| +| PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | +| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | +| PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | +| EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | +| SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | +| HEIC | `.heic` | ✓ | ✗ | | <1% | +| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | +| RTF | `.rtf` | ✓ | ✓ | | <1% | +| Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | +| Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% | +| InDesign Tagged Text | `.txt` | ✓ | ✓ import and export | | <1% | +| CSV / TSV (Data Merge) | | ✓ | ✓ (+ Excel) | | <1% | +| Audio / video | `.mp4`, `.mp3`, … | ✓ placeholders for EPUB/PDF | ~ placed as media objects | no in-app playback | <1% | + +## Resources and settings + +| Format | Ext | InDesign | DesignCraft | +|---|---|---|---| +| Adobe Swatch Exchange | `.ase` | read/write | ✓ load and save | +| Colour books | `.acb` | read (17 libraries ship) | ✗ (licensed libraries; Pantone etc. out of scope, own libraries possible) | +| ICC profiles | `.icc`, `.icm` | read | ✓ | +| Colour settings | `.csf` | read/write | ✗ | +| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | +| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | +| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | +| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | +| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | + +## Score + +Weighted by the Weight column (documents, output and placed tables; resources unweighted), +counting ✓ = 1, ~ = 0.5, and IDML at 0.6 until a real-file corpus proves it: **~45% present**. +Taking fidelity into account (no INDD, IDML unproven, PDF/X-1a and presets missing), **~40% ready +for real work** (estimated). INDD alone moves this dimension by up to 30 points. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| INDD import via a clean-room converter (owner decision, #114) | 20–60 | +| IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 | +| PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 | +| Print fidelity, PostScript level 3 output and print presets | 25–45 | +| ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 | +| HTML / fixed-layout EPUB with live text | 20–35 | +| JPEG export dialog, SVG export, EPS export | 10–20 | +| **Total** | **190–355** (150–250 excluding INDD and PostScript output) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 00000000..ebd2d7da --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,231 @@ +# Where DesignCraft falls short of InDesign + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) + +Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an +InDesign professional from doing real work. This is the work list: agents pick from the top. +Numbers and weights are in [target-app-parity.md](target-app-parity.md); the plan is in +[roadmap.md](roadmap.md). Estimates are Opus 5.5 agent-hours, calibrated in +target-app-parity.md. When you close a gap, delete its entry (or shrink it), add a line to the +ROADMAP progress log and update the timestamps. + +Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · **H** hardware · +**L** localization · **P** performance · **S** stability · **E** ecosystem · **A** AI. + +## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate)) + +### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER** +- **Missing:** threading a story from one frame to another. The canvas draws in and out ports + (`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text + cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking + a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP + (`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the + sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which + users report adding pages they can't delete (#165). +- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's + missing everywhere). +- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next + that the designer drew. That is InDesign's core text workflow. +- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and + click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7). +- **Doc:** [ui-parity.md](ui-parity.md) + +## Blocking (beta can't be declared while these are open) + +### 1. Basic editing fails for many users (U, S) +- **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF + paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't + open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields + (#189); character formatting set at a caret is discarded (#289). +- **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Impact:** a layout artist can't finish the simplest job. This dominates the + ready-for-real-work number. +- **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). +- **Doc:** [ui-parity.md](ui-parity.md) + +### 2. InDesign documents (.indd) can't be opened (FF) +- **Missing:** INDD (and INDT, INDB, INDL) reading. Everything InDesign users receive is INDD. +- **Evidence:** #21. Two clean-room converters are proposed: PR #98 and `indd-utils` (#114), + which reports 96.9% of InDesign's IDML values reproduced on ~650 INDD/IDML pairs, 0 failures on + ~4,300 files, and output that DesignCraft's IDML importer already reads. +- **Impact:** without it, every job starts with "ask the client for an IDML". +- **Estimate:** 20–60 h to integrate indd-utils (INDD → IDML → existing importer), warnings UI, + corpus test; **owner decision** on provenance and where the crate lives. +- **Doc:** [file-format-parity.md](file-format-parity.md) + +### 3. IDML round-trip isn't proven on real files (FF) +- **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or + a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not + rendered without a warning (#193), `$ID/` font names reported missing (#338), no + `PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop + open of IDML on the app icon (#211). +- **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic, + verified in InDesign 2026 with synthetic files), but nothing is measured on real documents. +- **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes + back to InDesign. +- **Estimate:** 60–100 h (corpus, metric, fixes). +- **Doc:** [file-format-parity.md](file-format-parity.md) + +### 4. Composition isn't compared with InDesign (T) +- **Missing:** an oracle harness: the same synthetic stories set by InDesign 2026 (observed + black-box, results kept under `plan/`) and by DesignCraft, comparing line breaks, overset and + frame fit. Known differences: optical kerning falls back to metrics, so imported text sets + wider (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline + (#180); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work" + (#87). +- **Impact:** an opened IDML reflows: different line breaks, different page count, overset text. + Professionals notice on the first page. +- **Estimate:** 80–140 h (harness 20–30, optical kerning 15–25, then fixes by cluster). +- **Doc:** [typography-parity.md](typography-parity.md) + +### 5. Startup crashes on some graphics hardware (S, H) +- **Missing:** a start that survives every GPU/driver: Metal device lost on Intel Macs (#334), + AMD OpenGL driver crash on Windows (#167), launch failures (#273, #220, #192, #166), blank or + frozen canvas after graphics loss (#90). +- **Evidence:** a backend fallback landed 2026-10-10 (#297: DirectX 12 on Windows, `gpu.json` + skip list); not yet confirmed by the reporters. +- **Estimate:** 15–30 h. +- **Doc:** [hardware-parity.md](hardware-parity.md) + +### 6. Installed fonts not always found (F) +- **Missing:** fonts in user font folders used for text (#161, #110, #107), fonts activated by + font managers and Adobe Fonts (#327, #261), reload when fonts change on disk (#328). +- **Impact:** documents set in the client's fonts render in Source Sans 3: wrong everywhere. +- **Estimate:** 10–20 h (VectorCraft has a port, #261). +- **Doc:** [typography-parity.md](typography-parity.md) + +### 7. No CI runs the tests on pull requests (S) +- **Missing:** a PR workflow running `cargo xtask ci` (fmt, clippy, tests, layers, wasm). Today + only FreeBSD and Windows ARM build checks run, and only when manifests change; `main` has no + branch protection. 65 PRs are open, some integrated in bulk (#259). +- **Impact:** regressions land unseen; the 1,122 tests protect nothing they aren't run on. +- **Estimate:** 4–8 h (plus runner minutes; owner decision on required checks). + +## High + +### 8. Canvas text input has no IME (U, L) +- **Missing:** composition input for Chinese, Japanese and Korean on the canvas (#322). + VectorCraft has an implementation to port. +- **Estimate:** 10–20 h. **Doc:** [localization-parity.md](localization-parity.md) + +### 9. PDF output presets and standards (FF) +- **Missing:** PDF/X-1a and PDF/X-3 (no flattener to PDF 1.3/1.4), Adobe PDF Presets and + `.joboptions` load/save, output-intent choice beyond the built-in profile, PDF/UA validation; + #159, #32. +- **Have:** PDF/X-4 with output intent and a validator (`#200`), PDF/A-2b, tagged PDF, CMYK and + spot, marks and bleed, the options dialog with remembered settings (#196). +- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md) + +### 10. Printing (H, F) +- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping. +- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through + craft-print: CUPS on macOS/Linux and the Windows PDF raster spooler. Physical printer fidelity + remains a hardware check. +- **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md) + +### 11. Paragraph and character style editing reliability (F) +- **Missing:** saving edits and deleting a paragraph style reported failing (#169); Tabs panel + for setting tab stops (#326). +- **Estimate:** 10–20 h. **Doc:** [ui-parity.md](ui-parity.md) + +### 12. Tables in depth (F) +- **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options + submenus as InDesign lays them out, Diagonal Lines, Convert Rows to Header/Body/Footer, Edit + Header/Footer, Go to Row; general reliability (#20). +- **Estimate:** 25–40 h. + +### 13. Hyphenation and spelling languages (T, L) +- **Missing:** hyphenation patterns for every language but English and Spanish; spelling for + every language but English. InDesign ships dictionaries for ~40 languages. +- **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need + licence review). **Doc:** [typography-parity.md](typography-parity.md) + +### 14. Windows and Linux are thinly tested (S) +- **Missing:** runtime testing on Windows and Linux: installer confusion (#64), pull-down panels + can't scroll on Linux Mint (#267), tab bar overlaps menus in some workspaces (#182), CJK UI + font missing on Linux (#332). +- **Estimate:** 30–60 h (smoke tests in CI, fixes). + +## Medium + +### 15. Long documents in depth (F) +- **Missing:** book-wide TOC and index; Table of Contents Styles and deeper production workflows. +- **Present:** basic style synchronization, continuous book numbering and merged PDF export. + Chapter updates prepare the full batch and write only closed native chapters; IDML is a + read-only source. Each file is atomic, but a later I/O failure can leave completed chapters; + the error lists them. Cross-file transactional saving remains unavailable. +- **Estimate:** 30–50 h. + +### 16. Menu items still missing (U) +- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes + and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference + update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to + Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User + Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, + Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026), + View ▸ Extras badges and Story Editor options. +- **Estimate:** 40–70 h (most are thin wrappers over existing engine features). +- **Doc:** [ui-parity.md](ui-parity.md) + +### 17. HTML and fixed-layout EPUB (FF) +- **Missing:** HTML export is one basic page (#314); fixed-layout EPUB writes page images, not + live text and CSS; no animation or timing for EPUB/HTML. +- **Estimate:** 30–50 h. + +### 18. Colour management depth (F) +- **Missing:** Assign Profiles / Convert to Profile, per-image rendering intents in output, + hex entry and colour picker issues (#323, #190), keep kerning on colour change (#29). +- **Estimate:** 20–35 h. + +### 19. Placed graphics depth (FF) +- **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer + comps beyond visibility; Edit Original / Edit With; PDF placed as editable vectors (#227, #24); + QuarkXPress import (#73). +- **Estimate:** 30–60 h (PostScript interpreter is a large separate project: +80–150 h). + +### 20. Performance on large documents (P) +- **Missing:** incremental composition (recompose from the edited paragraph; 8 ms keystroke + budget), benchmarks against InDesign on long books and image-heavy catalogues. +- **Have:** compose 300k characters ~38 ms warm, damage-region repaint (< 1 ms while typing), + parallel story composition. +- **Estimate:** 40–80 h. + +## Lower + +### 21. Localization breadth (L) +- **Missing:** Hindi, Indonesian, Korean, Vietnamese interfaces; ~17% of strings in the 83–85% + languages; 39% of pt-BR; native-speaker review for all. **Estimate:** 120–180 h. + **Doc:** [localization-parity.md](localization-parity.md) + +### 22. InDesign scripting compatibility (E) +- **Missing:** an ExtendScript / UXP-compatible DOM so existing InDesign scripts run. Our own + command API, scripts panel, CLI and MCP are strong and agent-friendly, but not compatible. +- **Estimate:** 150–250 h for a useful DOM subset; **owner decision**. + +### 23. Plug-ins (E) +- **Missing:** any plug-in model. InDesign's third-party ecosystem (imposition, preflight, data + publishing) doesn't carry over and can't. **Owner decision**; not estimated. + +### 24. AI features (A) +- **Missing:** InDesign 2026's Generative Expand, Text to Image and Auto Style equivalents; local + generative vector requested (#232). **Owner decision** on openly licensed models. +- **Estimate:** 60–120 h. + +### 25. Accessibility of the app itself (U) +- **Missing:** screen-reader exposure of panels and dialogs (egui AccessKit coverage unverified); + PDF/UA validation of exports. **Estimate:** 15–25 h. + +## Out of scope (by design) + +Adobe cloud services (CC Libraries, Stock, Adobe Fonts activation, Publish Online, Share for +Review, Creative Cloud sign-in), InCopy assignment workflows, Bridge, Exchange extensions, Version +Cue. These are excluded from every percentage. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) | +| 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 00000000..fb92c587 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,60 @@ +# Hardware and platform parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) + +What InDesign does with hardware, per platform, against DesignCraft. Layout is not a +GPU-hungry workload, so this dimension carries little weight, but a start that fails on a +graphics driver loses the user entirely. Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Hardware: ~45% ready, 20–40 h. Platforms: ~55% ready, 40–80 h.** (estimated) + +## Hardware features + +| Feature | InDesign 2026 | DesignCraft macOS | Windows | Linux | Web | +|---|---|---|---|---|---| +| GPU display (canvas) | GPU Performance: GPU preview, animated zoom (Metal on macOS; Windows with supported GPUs) | canvas rasterized on the CPU (vello_cpu, SIMD, multithreaded), presented by wgpu (Metal) | wgpu DirectX 12 (since #297), Vulkan/OpenGL fallback | Vulkan, OpenGL fallback | WebGPU, WebGL2 fallback | +| Start-up on any GPU | robust | Metal device lost on Intel Macs (#334) | AMD OpenGL crash (#167), launch failures (#273) | some reports (#192, #220) | | +| Backend fallback | n/a | `gpu.json` skips a backend whose start never showed a frame (#297) | same | same | | +| Animated / scrubby zoom | ✓ (GPU) | ✓ (CPU tiles, damage regions) | ✓ | ✓ | ✓ | +| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ | +| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ | +| Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ | +| Printing | system print with PPDs, PostScript, separations | PDF to CUPS (`craft-print`) | Windows PDF raster spooler (`craft-print`) | PDF to CUPS (`craft-print`) | browser download | +| Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ | +| Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ | +| Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded | + +## Platforms + +| Platform | InDesign 2026 | DesignCraft | Evidence | +|---|---|---|---| +| macOS (Apple silicon, Intel) | ✓ | ✓ primary development platform | Intel GPU start failure (#334); window controls misaligned (#333); font managers (#327) | +| Windows x64 | ✓ | ~ builds and installers ship | copy/paste and context menus (#351, #312), installer confusion (#64), launch failures (#273, #167) | +| Windows ARM64 | ✓ | ~ build (CI workflow) | not exercised at runtime | +| Linux (x86_64, aarch64; riscv64 tar.gz) | ✗ | ~ deb, AppImage, Flatpak | clipboard (#164), panels don't scroll (#267), CJK UI font (#332) | +| FreeBSD | ✗ | ~ build | | +| Web (WASM) | ✗ (InDesign on the web is not shipped) | ~ same UI, file picker, downloads; no control channel | | + +DesignCraft runs on more platforms than InDesign; what's missing is runtime testing on the ones +the developers don't use daily. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 | +| Printing fidelity, PostScript and print presets | 15–30 (counted in file-format-parity.md) | +| Monitor profile in display colour management | 5–10 | +| Windows and Linux runtime smoke tests in CI | 20–40 | +| Multi-window document views | 15–30 | +| **Hardware + platforms** | **60–120** | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker | + +Windows submission now uses shared craft-print. It selects matching or default driver paper, +shrinks oversized sheets to the printable area without enlarging smaller ones, and preserves +the queue's duplex default; printer-driver options UI and physical output validation remain open. diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 00000000..d3605320 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,80 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6) + +Per-language status of the DesignCraft interface and of document typesetting in each script. +Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), +[arabic-typography.md](arabic-typography.md). Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Dimension score: ~45% ready (estimated), 120–180 h** plus native-speaker review (human). + +## How it was measured + +Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French, +Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`. +Untranslated strings fall back to English. A script (2026-10-10) built the set of English +interface strings as the union of every catalog's keys, every engine command label and every +literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals +in the UI crate appear in no catalog (some are test data or proper names), so the true total is +somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no +help content in any language (Help ▸ opens the project's pages). + +InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es, +fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew +come as Middle East editions with an English or French interface and right-to-left features. + +## Key languages + +| Language | Code | InDesign 2026 UI | UI strings translated | Dialogs / tooltips / help | Script support (documents and UI) | Native review | Status | To `full` (h) | +|---|---|---|---|---|---|---|---|---| +| English | en | ✓ | 1,724 / 1,724 (100%) | all / all / none | Latin ✓ | yes | full | 0 | +| Simplified Chinese (Mandarin) | zh | ✓ (zh_CN) | 1,438 (83%) | menus, panels, most dialogs; tooltips partly; no help | CJK typesetting ✓ (cjk.md); **no canvas IME** (#322); CJK UI font not embedded in release builds, so boxes on Linux (#332) | no | partial | 25–40 | +| Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | +| Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | +| Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | +| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | +| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; no Portuguese hyphenation | no | partial | 8–12 | +| Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | +| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) | +| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | +| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; **no IME** (#322) | no | none | 15–25 | +| Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 | + +Percentages are of the 1,724 measured strings. A language is `full` only when every user-visible +string is translated and its script renders and edits correctly; none but English is. + +## Other shipped languages + +| Language | Code | UI strings translated | Status | +|---|---|---|---| +| Italian | it | 1,464 (85%) | partial | +| Ukrainian | uk | 1,432 (83%), plural categories handled | partial | + +DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its +eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). + +## Document languages + +InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). +DesignCraft hyphenates **English and Spanish** and spell-checks **English** only. Text language +can be set per paragraph and character (Properties, Character panel) and drives quotes and +locale shaping. See [typography-parity.md](typography-parity.md). + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Fill the 83–85% catalogs (de, fr, es, zh, ar, it, uk; ~290 strings each) | 25–40 | +| pt-BR to full | 8–12 | +| New: Hindi, Indonesian, Korean, Vietnamese (1,724 strings, plural rules, tests) | 50–75 | +| Canvas IME (zh, ja, ko) | 10–20 | +| CJK UI font in every release build; Devanagari and Vietnamese rendering tests | 10–15 | +| Catalog completeness test: every `tr` key and command label present in every catalog | 4–8 | +| Help content (at least English, then the twelve) | 15–25 | +| **Total** | **120–180** + native-speaker review for 11 languages (human) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | diff --git a/docs/mcp.md b/docs/mcp.md index 772f8a91..f3558574 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,13 +51,16 @@ claude mcp add designcraft -- designcraft-cli mcp | UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work | In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so -what the agent does shows up live in the window. The connection is re-established once if the app restarts. +what the agent does shows up live in the window. A failed request is not resent: the operation may have completed, +so inspect the document before retrying. The next request re-establishes the connection. ## Coordinates Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in -`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, +`render_page` / `export_png` are 0-based. Commands that create items take `spread`: a body spread index, or +`{"kind": "parent", "index": 0}` for a parent spread (its index in `parents` of `inspect_document`); any other +value is an error. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). ## Text offsets diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 92% rename from docs/parity.md rename to docs/parity-checklist.md index 94b36473..d2d1ac11 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,8 +1,19 @@ -# Feature parity with InDesign +# InDesign feature checklist -The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a row, re-run). +> **Generated:** summary by `cargo xtask parity` from the rows below; rows last re-scored 2026-10-04, summary unchanged since (checked 2026-10-10) · **Target:** Adobe InDesign 2026 -Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. +This is the **presence** checklist: does each InDesign feature exist in DesignCraft? It answers +the feature-breadth question only. Depth, fidelity and readiness for real work are assessed in +[target-app-parity.md](target-app-parity.md); individual shortfalls are in [gaps.md](gaps.md). +Formerly `docs/parity.md`. + +Edit a row's status, then run `cargo xtask parity` to regenerate the summary; don't edit the +summary by hand. Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. + +Caveat (2026-10-10 review): rows were graded by the agents that built the features, at a coarse +grain. A measured match of InDesign 21.6's own menu dump against DesignCraft's labels finds 69% +of in-scope menu items (75% against the documented menu tree) (see [target-app-parity.md](target-app-parity.md)), so read 99% as an upper +bound. **Weighted breadth parity: 99%** over 275 features. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..d80fe7aa --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,115 @@ +# DesignCraft roadmap (detail) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) + +Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in +[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are +Opus 5.5 agent-hours (calibration in target-app-parity.md). Driving the app from agents: +[agents.md](agents.md). + +## Alpha gate + +InDesign's core workflows: what a layout professional does with it every day. DesignCraft is +alpha only when every one works end to end on macOS (its main platform) and the work saves and +reopens. **Gate result (2026-10-10): fails, so the stage is pre-alpha.** Rows marked "no" are the +alpha checklist; the partial rows don't block the workflow on macOS (there is a working path) +but are next in line. + +| Core workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | +| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 | +| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | +| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | +| Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 | +| Export a print-ready PDF (bleed, marks, CMYK and spot, PDF/X-4) | yes | krilla export with subset fonts, Separations, marks and bleed, PDF/X-4 with validator; 53 PDF tests | 0 | + +Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part +of the alpha gate: IDML works both ways today and INDD is gap 2. + +**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above +the ~40% alpha bar. + +## Current focus + +In order. Each closes a beta blocker from [gaps.md](gaps.md). + +0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands, + out-port click with a loaded text cursor (click a frame or draw one), unthread by + double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h. +1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on + macOS, Windows and Linux. 30–50 h. +2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux; + Windows smoke start. 4–8 h. Needs the owner to make it a required check. +3. **Open INDD** (gap 2): decide with the owner how to bring in the clean-room converter + (#114 indd-utils, #98), then INDD → IDML → existing importer, warnings shown. 20–60 h. +4. **IDML real-file corpus** (gap 3): a pinned corpus, a survival metric, and fixes for #193, + #338, #339, #340. 60–100 h. +5. **Composition oracle** (gap 4): synthetic stories set in InDesign 2026 vs DesignCraft; + optical kerning; the overset rule. 80–140 h. +6. **Start on any GPU; find every installed font** (gaps 5, 6). 25–50 h. + +## Milestones + +States re-assessed 2026-10-10 against the code and the issue tracker. "Done" means the feature +set exists; depth gaps are in gaps.md. + +| # | Milestone | State | +|---|---|---| +| M0 | Skeleton + vertical slice | ✅ | +| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | +| M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | +| M4 | Color & effects | ✅ core; colour management depth | +| M5 | Graphics & links | ✅ core; EPS preview only | +| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | IDML ✅, PDF ✅ (X-4, A-2b, tagged); INDD ✗; PDF/X-1a/X-3 and presets ✗ | +| M7 | Long documents (sections, TOC, index, footnotes, books) | ✅ except Books (shallow) | +| M8 | Tables | ✅ core (table/cell styles, rotation); diagonal lines, anchored objects in cells (#163) | +| M9 | Performance (MT composition, tiles) | in progress: parallel compose 6.7×, damage regions; incremental composition next | +| M10 | Find/Change, spelling, Preflight | ✅ (spelling English only) | +| M11 | Layout power features (liquid/alternate layouts, data merge) | present, shallow; Flex Layout (new in InDesign 2026) ✗ | +| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | +| M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | +| M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started | +| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate | +| **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below | + +## To beta + +Beta needs ~75% ready for real work and no blocking gap in the main file format. From ~47% today +that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to five agents): + +| Work | Gap | Hours | +|---|---|---| +| Basic editing on every platform | 1 | 30–50 | +| INDD import | 2 | 20–60 | +| IDML real-file corpus and fixes | 3 | 60–100 | +| Composition oracle and fixes | 4, 13 | 80–140 | +| Start-up robustness, fonts | 5, 6 | 25–50 | +| CI, Windows/Linux runtime tests | 7, 14 | 35–70 | +| IME | 8 | 10–20 | +| PDF/X-1a/X-3, presets, physical print validation | 9, 10 | 65–110 | +| Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 | +| Long documents (Books) | 15 | 30–50 | +| **Total** | | **420–750** | + +## After beta: to full parity + +Remaining gaps 16–25: the rest of the menu long tail, HTML/EPUB depth, colour management, placed +graphics depth (PostScript), performance on large documents, localization to `full` in the +twelve languages, InDesign scripting compatibility (owner decision), AI features (owner decision), +accessibility. **730–1,200 h** more; **1,150–1,950 h** in total from today. + +## Superseded plan + +The order the project followed until 2026-10-04 ("Next: books · New Window / split view · Ink +Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 +output intent · EPS place") is done except Books depth and EPS (preview only). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | +| 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | +| 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 00000000..eb7d7e5c --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,452 @@ +# DesignCraft parity with Adobe InDesign + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) + +The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) +summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature +presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask parity`). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each InDesign feature exist?) | **~80%** (range 69–99%) | measured lower and upper bounds, estimate between them (below) | +| **Ready for real work** (could an InDesign professional switch for real jobs?) | **~47%** (range 42–52%) | estimated, weighted sum (below); 1,150–1,950 h to ~95% | +| Mainstream practitioner (weekly core of a typical InDesign pro) | **~42%** | 500–850 h to ~95%; estimated, [method below](#mainstream-practitioner-42) | +| Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) | +| Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated | +| Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | +| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | + +## How breadth was measured + +Two measurements bracket it, and neither is the answer by itself. + +- **Checklist: 99% (upper bound, measured, self-graded rows).** `cargo xtask parity` scores the 275 + rows of [parity-checklist.md](parity-checklist.md), weighted P0 = 3, P1 = 2, P2 = 1: 269 done, + 6 partial, 0 missing. The rows are a catalogue we wrote and were graded by the agents that built + the features; "done" means the feature exists, not that it has all of InDesign's options. +- **Menu dump: 69% (lower bound, measured 2026-10-10).** The main menu tree of the running + InDesign 2026 21.6, dumped through its scripting DOM during black-box observation + (`plan/indesign/screenshots/menus.txt`, local, never committed: 1,121 entries, 798 leaf items), + was matched by a script against every label in the DesignCraft source (engine command labels, + `UI_COMMANDS`, the `MENUS` table, dialog and panel strings; case, punctuation and "Show/Hide" + variants normalized). Excluded as out of scope or dynamic: the installed-font list (490 items), + Plug-Ins, Adobe cloud and AI services (Firefly, Generate, Rewrite, Auto Style, Stock, CC + Libraries, Publish Online, Share for Review), InCopy, Help links, window tiling, recent files + and workspace names. **331 of 482 in-scope items match (68.7%)**: File 19/26, Edit 28/31, + Layout 21/27, Type 49/92, Object 81/110, Table 26/45, View 40/57, Window 50/73, app menu 17/20. + It undercounts: some items exist under another label or only in a panel menu (Notes and Track + Changes have panels and `note.*` / `changes.*` commands but not InDesign's Type ▸ Notes / + Track Changes submenus). +- **Menu tree from documentation: 75% (cross-check).** The same match against the clean-room menu + tree compiled from public documentation (`plan/indesign/04-menu-tree.md`, 689 items): 440 of 590 + in-scope items (74.6%). +- **Command surface (measured):** 446 engine commands (188 placed in menus) + 99 UI commands; the + menu bar has 9 menus, 49 submenus and 340 items. The original plan targeted ~1,000 commands. + +The breadth figure is taken between them, at **~80%**, because the checklist overstates (rows were +written at a coarse grain and graded by their builders) and the label match understates (label +differences, panel-only commands). + +## How "ready for real work" was estimated + +Each dimension below was judged from the code, the tests, the issue tracker (124 issues, 79 open on +2026-10-10, most from people trying the 0.4/0.5 releases on real work) and the InDesign 2026 +reference material. Weights reflect what an InDesign professional hits first: opening the files +they receive, setting type exactly, and basic editing that never fails. + +| Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | +|---|---|---|---|---|---| +| Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English and Spanish only | [typography-parity.md](typography-parity.md) | +| File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | +| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) | +| Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | +| Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) | +| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | +| Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) | +| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) | +| Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) | +| AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) | +| **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | | + +**How the number is built:** the plain weighted sum of the table above, per the craftrules +standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2×45 + 2×15 = 47.2% → +**~47%** (range 42–52%, ±5 points of judgement in the dimension values). An earlier version +today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That +multiplicative step was dropped to match the standard (discounts belong only to the mainstream +and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, +and the missing manual threading is what holds the stage at pre-alpha through the gate. + +## Mainstream practitioner and essentials user + +Two narrower questions, computed with the craftrules method so every app's numbers compare. The +stage still follows the full number (~47%) and the core-workflow gate. All three are estimates. + +| Audience | Ready | Opus 5.5 agent-hours to ~95% | Parallelizes | Work that dominates | +|---|---|---|---|---| +| Full target (ready for real work) | **~47%** | **1,150–1,950** | ~70% (per crate) | depth across all 19 areas, InDesign scripting compatibility, localization to full in 12 languages, typography fidelity, INDD/IDML exchange | +| Mainstream practitioner | **~42%** | **500–850** | ~65% | INDD import and IDML real-file corpus (80–160), composition fidelity against InDesign (120–200), depth of the weekly areas (150–240), PDF/X-1a, presets and printing (65–110), editing and threading (45–75), stability and CI (25–45), fonts (10–20) | +| Essentials user | **~54%** | **135–250** | ~50% | start-up robustness on every GPU (25–45), editing and discoverability bugs: context menus, copy/paste, installer (30–50), threading (15–25), text editing and fonts (20–40), printing on Windows (15–30), depth of the core features (30–60) | + +Hours are calibrated as in [Remaining effort and calibration](#remaining-effort-and-calibration) +(1–2 h per feature arc, 2–4 h per behaviour cluster matched against an oracle). Each audience's +work is a subset of the one above it: essentials ⊂ mainstream ⊂ full. Human needs: the owner's +decision on INDD (#114), InDesign oracle captures on the owner's Mac, native-speaker review (full +only). + +### Mainstream practitioner: ~42% + +The typical InDesign professional (magazine, book, brochure, packaging-adjacent layout artist) +uses these weekly: the alpha-gate workflows plus their everyday tools. Excluded: plug-ins and +scripting compatibility, generative/cloud AI, InCopy and team workflows, specialist hardware, +interface languages other than the user's own. Depth here is what the feature does when it +works; interaction, stability and exchange problems are the discounts, so they aren't counted +twice. + +| Area | Weight | Depth | Evidence | +|---|---|---|---| +| Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table | +| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only | +| Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange | +| Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply | +| Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction | +| Colour, swatches, stroke | 8% | 60% | hex entry, CMS depth | +| Images: place, fit, links, wrap | 10% | 60% | EPS preview only, Edit Original missing | +| Tables | 5% | 55% | anchored objects in cells, Table Options submenus | +| Layers | 3% | 75% | | +| Effects and transparency | 3% | 50% | fidelity unmeasured | +| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a or presets; physical print validation pending | +| View, navigation, undo, save | 3% | 70% | | +| **Weighted depth** | 100% | **60.7%** | | + +Discounts (multiplicative): + +| Discount | Factor | Evidence | +|---|---|---| +| Interaction fidelity | ×0.88 | right-click menus fail for some macOS users (#160, #238) and on Linux (#354); Paste in Place lands on page 1 (#348); formatting at a caret discarded (#289); font search focus (#335, #86) | +| Stability on real machines | ×0.92 | start-up failures on Intel Mac Metal (#334) and Windows drivers (#167, #273, #220, #192, #166); no CI on pull requests; macOS on Apple silicon is solid | +| File exchange with InDesign users | ×0.85 | `.indd` can't be opened (#21); IDML import/export bugs (#193, #338, #339, #340, #211); opened text reflows: optical kerning (#181), overset rule (#180). Harsher than VectorCraft's ×0.92 because InDesign pros receive and send native files daily | + +60.7% × 0.88 × 0.92 × 0.85 = 41.8% → **~42%**. It is lower than the full number (47%) +because the full number spreads weight over dimensions DesignCraft does relatively well +(performance, platforms, breadth of the long tail: 99% checklist presence), while the weekly core +carries the gaps at full weight: threading, editing bugs and INDD exchange. + +### Essentials user: ~54% + +Someone making a flyer, poster, newsletter or simple booklet: they touch only the core. +Excluded: advanced options, pro workflows (preflight, separations, books, long-document +features), exchange edge cases, and everything excluded above. + +| Core feature | Weight | Depth | Evidence | +|---|---|---|---| +| Launch, welcome screen, new document from a preset | 10% | 80% | presets incl. A6 (#248) | +| Type text in a frame, select and edit it | 15% | 65% | caret formatting (#289), double-click selection in fields (#189) | +| Font, size, colour, alignment | 15% | 65% | installed fonts not always found (#161, #110, #107) | +| Place an image and fit it | 10% | 75% | place gun, fitting | +| Draw shapes and lines, fill and stroke | 10% | 80% | | +| Move, resize, rotate, align, duplicate | 10% | 75% | | +| Copy and paste | 5% | 60% | works on macOS; Paste in Place to the wrong page (#348) | +| Undo / redo | 5% | 85% | | +| Save and reopen | 5% | 85% | `.designcraft` round trip, recovery | +| Export PDF / PNG | 10% | 80% | | +| Print | 5% | 40% | PDF to CUPS/Windows via craft-print; physical output validation pending | +| **Weighted depth** | 100% | **72.0%** | | + +| Discount | Factor | Evidence | +|---|---|---| +| Launch and stability | ×0.90 | the start-up failures above hit casual users first and they don't know `WGPU_BACKEND` | +| Discoverability and UI clarity | ×0.88 | a faithful, dense InDesign UI; right-click menus don't open for some users; the out port looks clickable but does nothing; Windows installer confusing (#64) | +| Opening files people send them | ×0.95 | casual users get Word, images and PDFs, which place well (docx/rtf/xlsx, PDF pages); INDD is rare for them | + +72.0% × 0.90 × 0.88 × 0.95 = 54.2% → **~54%**. + +### User evidence (GitHub issues and comments, 2026-10-10) + +126 issues and 94 comments; no issues were filed by the maintainer (echelon). + +- **Praise:** 7 people in 6 threads (#32 "so damn impressive… InDesign open source for Linux", + #77, #107, #113, #197, #328). +- **Switched-from-InDesign reports:** 0 completed switches. 2 former InDesign users trying real + work: #348, a retired designer who lost an Adobe licence, laying out 18 pages; #140. Both were + blocked by core-path bugs (paste to page, threading). #320 is an art director with 10 years of + daily InDesign asking for a feature. +- **Open issues (81):** + - 44 are core-path bugs (54%), clustering into a few root causes: copy/paste (8), + context menus (5), threading (4), launch (6), fonts (5). + - 9 are InDesign file-exchange bugs. + - 26 are niche requests or polish (HTML export, QuarkXPress import, MCP gateway, theme options). + - 2 are meta. + +So the user evidence matches the numbers: interest and goodwill are high, and real work is +stopped by a short list of core-path bugs. + +### Why the number went down + +The 2026-10-04 estimate said "~83% including depth" and 170 h remaining. It was written before +anyone outside the project had used the app. New evidence since then: + +- 79 open issues, many about core editing on Windows and Linux (copy/paste, context menus, + threading, fonts not found, startup crashes), filed against the 0.4 and 0.5 releases. +- The first measured menu coverage: 69% of the in-scope items of InDesign 21.6's own menu dump. +- The main file format: InDesign users receive `.indd` files, which DesignCraft can't open. The + previous estimate treated INDD as out of scope; a professional can't. +- No comparison of composition against InDesign exists, so typographic fidelity is unproven. + +## Features by area + +Breadth is the checklist's measured score; Ready is this review's estimate of depth and reliability. +Hours overlap with the dimension table (UI and format work sits in both views); the dimension +table is the one summed. + +| Area | Breadth (checklist) | Ready | Hours | Main gaps | +|---|---|---|---|---| +| Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | +| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | +| Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | +| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, live-corner handle (#186), reference point relocation (#337), Join/Reverse Path/Convert Point menu | +| Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | +| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | +| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | +| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | +| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | +| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | +| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | +| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles | +| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback | +| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | +| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth | +| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) | +| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent | +| Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI | + +## Remaining effort and calibration + +**Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10 +`origin/main` had 470 commits (77 merged pull requests), 126,411 lines of Rust in 276 files and +1,122 `#[test]` functions. Commit timestamps show ~43 wall-clock hours of activity (gaps under +45 minutes), with one to five agents working in parallel, so roughly **100–150 Opus 5.5 +agent-hours** built the current app: ~0.25 h per commit, **1–2 h per feature arc** (a command, +dialog, IDML and PDF support, tests). The community fixes of 2026-10-09/10 (text composition edge +cases #282–#298, 40–400 lines each) took about **1–2 h each**. Depth work against an oracle +(matching InDesign's behaviour exactly) is slower than adding features: **2–4 h per behaviour +cluster**, more when the oracle has to be built first. + +| Work | Opus 5.5 h | Parallelizes? | Needs a human | +|---|---|---|---| +| Beta blockers (see [roadmap.md](roadmap.md)) | 420–750 | ~70% (per crate) | owner decision on INDD import (#114); InDesign oracle captures on the owner's Mac | +| Rest of full parity | 730–1,200 | ~70% | native-speaker review for 12 languages; owner decision on ExtendScript compatibility and AI models | +| **Total to full parity** | **1,150–1,950** | | | + +With four to five agents in parallel and an integrator, the beta work is ~120–200 wall-clock +hours. + +## Methodology and sources + +- **Target:** Adobe InDesign 2026, version 21.6.0.57, installed at `/Applications/Adobe InDesign + 2026/`. Inspected without launching and without reading anything inside the bundle beyond names + and listings (clean-room rule, AGENTS.md): `Info.plist` `CFBundleDocumentTypes` and + `UTExportedTypeDeclarations` (formats), `.lproj` folder names (25 UI localizations), plug-in + folder names (filters), `Presets/` folder names (swatch libraries, workspaces, shortcut sets). +- **Reference material:** `plan/indesign/01–11` (local, gitignored): overview, UI/UX, feature + catalogue, menu tree, tools, panels, dialogs, file formats, shortcuts, typography, and + observations of the running app. +- **DesignCraft measured from source on `origin/main` (92649ff, 2026-10-10)**: command registry, + `MENUS`, i18n catalogs, crate line and test counts, format modules. No local build (disk). +- **User evidence:** GitHub issues #1–#352 and open PRs (65 open, including the INDD converter + proposals #98 and #114). + +## Appendix: what works today + +The inventory below was the "Working today" list of ROADMAP.md (moved here 2026-10-10, unchanged). +It is evidence of breadth: each item exists. It is not a claim of InDesign-level depth. + +- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. +- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. +- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. +- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. +- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. +- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. +- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. +- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. +- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. +- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. +- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. +- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. +- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. +- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). +- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. +- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. +- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. +- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). +- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. +- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. +- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). +- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. +- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts. +- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style). +- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics. +- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. +- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. +- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML. +- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. +- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. +- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. +- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). +- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). +- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). +- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. +- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. +- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. +- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). +- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). +- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). +- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. +- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. +- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). +- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. +- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. +- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. +- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. +- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. +- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. +- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). +- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). +- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. +- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. +- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. +- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. +- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). +- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. +- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). +- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. +- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. +- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. +- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. +- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path. +- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position. +- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. +- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. +- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. +- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). +- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. +- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. +- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. +- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. +- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. +- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. +- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). +- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. +- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. +- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. +- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). +- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). +- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). +- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `