Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

223 changed files with 3329 additions and 43281 deletions

View File

@ -23,7 +23,34 @@ permissions:
jobs:
freebsd:
# 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
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
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 https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
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

View File

@ -19,5 +19,50 @@ concurrency:
jobs:
lint:
# 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
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/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 '<Icon Id="[^"]*"' packaging/windows/designcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.designcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.designcraft.metainfo.xml.in >"$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

View File

@ -1,3 +1,12 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "DesignCraft v<version>". 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:
@ -11,33 +20,404 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
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@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### 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@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo 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@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo 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@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo 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@v4
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@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
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@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "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 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/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@v4
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
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo 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@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
# 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 }}
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@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="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"

View File

@ -26,10 +26,70 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
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
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: 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@v4
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'

View File

@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
@ -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) 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.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
## Contributor credits (About window)

View File

@ -8,13 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below |
| `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
| `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
| `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) |
| `assets/icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/saucecontrol/Compact-ICC-Profiles, `profiles/sRGB-v2-magic.icc` at `abb1b6f48570b4eb0fabd8566e517dca07e617d4`, SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | Public Domain (CC0-1.0) | ICC v2.1 sRGB profile, 736 bytes. PDF/X-4 export tags RGB images that have no profile of their own with it |
| `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
| `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
| `assets/fonts/Inter-SemiBold.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |

View File

@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
@ -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) 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.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.

182
Cargo.lock generated
View File

@ -635,21 +635,6 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "caseless"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
dependencies = [
"unicode-normalization",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.5.1"
@ -796,23 +781,6 @@ 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"
@ -887,9 +855,8 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"arboard",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
@ -897,7 +864,6 @@ dependencies = [
"log",
"muda",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
@ -905,7 +871,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -921,7 +887,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"kurbo",
"moxcms",
@ -933,25 +899,23 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"hypher",
"log",
"miniz_oxide 0.8.9",
"proptest",
"regex",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
]
[[package]]
name = "designcraft-doc"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@ -963,10 +927,8 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"caseless",
"craft-print",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@ -989,16 +951,13 @@ dependencies = [
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"unicode-segmentation",
"wasm-bindgen-test",
"zip",
]
[[package]]
name = "designcraft-epub"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@ -1007,7 +966,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@ -1019,7 +978,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-doc",
"serde",
@ -1030,7 +989,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"flo_curves",
"kurbo",
@ -1043,10 +1002,9 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-geom",
"quick-xml 0.38.4",
@ -1056,7 +1014,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@ -1067,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@ -1080,7 +1038,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1102,7 +1060,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1120,7 +1078,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@ -1129,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1141,16 +1099,14 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"craft-ui",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-engine",
"designcraft-fonts",
"designcraft-geom",
"designcraft-idml",
"designcraft-render",
"designcraft-tools",
"egui",
@ -1159,24 +1115,20 @@ dependencies = [
"regex",
"serde",
"serde_json",
"tempfile",
"toml",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
"zip",
]
[[package]]
name = "designcraft-web"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
"designcraft-ui-egui",
"eframe",
"egui",
"form_urlencoded",
"js-sys",
"log",
"rfd",
@ -1486,7 +1438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2123,12 +2075,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hypher"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6"
[[package]]
name = "icu_collections"
version = "2.3.0"
@ -2604,16 +2550,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2660,7 +2596,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -2737,15 +2673,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -3095,12 +3022,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
@ -3852,7 +3773,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4267,7 +4188,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4502,7 +4423,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -4541,15 +4462,6 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@ -4834,45 +4746,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wayland-backend"
version = "0.3.17"
@ -5239,7 +5112,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5520,7 +5393,8 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",
@ -5688,7 +5562,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.1"
version = "0.5.0"
dependencies = [
"serde_json",
]

View File

@ -4,14 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
rust-version = "1.90"
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" }
@ -43,9 +42,7 @@ unicode-bidi = "0.3"
unicode-script = "0.5"
unicode-bidi-mirroring = "0.4"
unicode-segmentation = "1"
caseless = "0.2"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
tempfile = "3"
# text / rendering / formats
skrifa = "0.47"
@ -68,8 +65,6 @@ 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 }
@ -97,7 +92,3 @@ 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" }

34
NOTICE
View File

@ -18,40 +18,6 @@ list (author, source, license) is in ASSETS.md.
- English hyphenation word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II
list by Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
(assets/hyphenation/en-us.pat) are trained from it by DesignCraft and released as CC0-1.0.
- Spanish hyphenation patterns, embedded through the hypher crate (feature "spanish"):
hyph-es.tex 5.0 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
Copyright (C) 1993, 1997 Javier Bezos, 2001-2019 Javier Bezos, CervanTeX. MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions: The above copyright notice
and this permission notice shall be included in all copies or substantial portions of the
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
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,

View File

@ -87,8 +87,7 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
Line breaks are identical on screen and in PDF.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b)
and EPUB export.
- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap.
No subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
@ -98,15 +97,12 @@ 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
```
@ -126,38 +122,6 @@ include it.
To drive a running app, send JSON lines to `127.0.0.1:7979`. The protocol is described in
[`docs/control-protocol.md`](docs/control-protocol.md).
### Logs
The desktop app writes its `log` records to standard error and to `logs/designcraft.log` in the
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or
`$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`;
Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no
terminal, so this file is what to attach to a bug report: the crash guard's panic report and a
failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log`
(and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start.
The file stops growing at 16 MiB. `--version` writes no file.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). |
| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. |
The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead.
### Graphics backend
The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A
graphics driver that faults takes the process down before any Rust code can catch it, so the
backend is chosen before the window exists, and a start that never showed a frame is remembered:
`gpu.json` in the settings directory records the backend being tried until the first frame has
been presented. A record left behind means that backend crashed (or the app was killed) at
start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows;
Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every
backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to
start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an
integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is
the Windows default. The code is `apps/designcraft/src/gpu.rs`.
### Web
```sh
@ -186,7 +150,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) (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)
- **Status and milestones:** [ROADMAP.md](ROADMAP.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)
@ -224,8 +188,6 @@ Installers and executables are code-signed.
| Fedora/RHEL/openSUSE | `designcraft-<ver>-linux-x86_64.rpm` | `designcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `designcraft-<ver>-linux-x86_64.tar.gz` | `designcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer).
### FreeBSD
| Build | File |

View File

@ -1,210 +1,221 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 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%)
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished.
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.
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
## Status (2026-10-01)
| | Value | Kind |
**Working today**
- 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.
- 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, 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`.
- 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), 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.
- 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).
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- 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.
- 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, `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`). Hyphenation dictionaries for other languages aren'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.
- 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`).
- 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 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.
- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages.
- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text.
- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come.
- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads.
- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page.
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** books · New Window / split view · Ink Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 output intent (needs krilla support) · EPS place.
## How far from full parity (estimate, 2026-10-04)
**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**.
**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with
four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
| Open P1 (none partial) | 0 h |
| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, warichu) | 14 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
Basis: since the first estimate this effort landed about 45 arcs (from parent overrides, Preferences, Stroke panel,
gradients and Pathfinder to table/cell styles, nested/GREP styles and Print) in roughly 14 hours on a heavily loaded
machine — about 1–2 hours per catalogue row.
## Agents: CLI and MCP
Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the
running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the
same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md).
## Milestones
| # | Milestone | State |
|---|---|---|
| **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). Text threads by hand (click a port, then click or
draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users
can't delete (#165). 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 the Primary Text Frame page fixes are 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:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 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** · Text threads by hand: click a selected text frame's in or out port to load
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
frame just added; click the linked frame or double-click a port to break the thread.
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
readiness percentage change.
- **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-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) |
| 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 |
| 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 | |

View File

@ -1,8 +1,7 @@
//! Headless DesignCraft.
//!
//! ```text
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR]
//! # --page, --scale and --pdf-options apply to the exports that follow them
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@ -12,7 +11,6 @@
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
//! designcraft-cli links # Discord, website, app page and GitHub links
//! designcraft-cli --version # print the version
//! ```
@ -35,50 +33,6 @@ mod perf;
use designcraft_engine::Session;
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!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
)
}
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
@ -116,22 +70,21 @@ fn main() -> ExitCode {
Some("mcp") => report(mcp(&args[1..])),
Some("perf") => report(perf::perf(&args[1..])),
Some("bench") => report(perf::bench(&args[1..])),
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
Some("links") => {
use designcraft_engine::links::*;
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
// Asking for help is not an error: it goes to stdout and succeeds. An unknown command
// still prints the same usage to stderr and fails.
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
outln!("{}", usage_footer());
ExitCode::SUCCESS
}
_ => {
eprintln!("{USAGE}");
eprintln!("{}", usage_footer());
eprintln!(
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\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"
);
eprintln!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
);
ExitCode::FAILURE
}
}
@ -147,16 +100,6 @@ fn report(r: Result<(), String>) -> ExitCode {
}
}
fn validate_pdf(path: Option<&str>) -> Result<(), String> {
let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?;
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let issues = designcraft_pdf::check_pdfx4(&bytes);
let valid = issues.is_empty();
let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues});
outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) }
}
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
fn mcp(args: &[String]) -> Result<(), String> {
@ -193,36 +136,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it.
/// They apply to the exports that follow them, so one after the last export would do nothing.
fn option_after_last_export(args: &[String]) -> Option<&str> {
let mut unused = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--page" | "--scale" | "--pdf-options" => {
unused = unused.or(Some(a.as_str()));
it.next();
}
"--export" | "--all-pages" => {
unused = None;
it.next();
}
"--in" | "--cmd" => {
it.next();
}
_ => {}
}
}
unused
}
fn run(args: &[String]) -> Result<(), String> {
if let Some(opt) = option_after_last_export(args) {
return Err(format!(
"{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export"
));
}
let mut s = Session::new();
let mut page = 0usize;
let mut scale = 1.0f64;
@ -312,7 +226,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() };
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
std::fs::write(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(())
}
@ -366,10 +280,6 @@ 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}`")),
}

View File

@ -67,41 +67,6 @@ fn run_cmd_references_and_describe() {
assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote")));
}
/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an
/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same
/// usage on stderr and fails.
#[test]
fn help_goes_to_stdout_and_unknown_args_to_stderr() {
for flag in ["--help", "-h", "help"] {
let out = cli().arg(flag).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{flag}: {:?}", out.status);
assert!(stdout.starts_with("usage: designcraft-cli"), "{flag}: {stdout:?}");
assert!(stdout.contains("designcraft-cli --version"), "{flag}: {stdout:?}");
assert!(out.stderr.is_empty(), "{flag} wrote to stderr: {}", String::from_utf8_lossy(&out.stderr));
}
// An unknown option is still an error: usage on stderr, nothing on stdout, non-zero exit.
let out = cli().arg("--no-such-option").output().unwrap();
assert!(!out.status.success(), "{:?}", out.status);
assert!(out.stdout.is_empty(), "unexpected stdout: {}", String::from_utf8_lossy(&out.stdout));
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]
@ -115,57 +80,3 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
/// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export
/// after it was silently ignored.
#[test]
fn run_export_options_come_before_their_export() {
let dir = std::env::temp_dir().join(format!("dc-cli-order-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap());
for (opt, val) in [("--scale", "0.5"), ("--page", "1"), ("--pdf-options", r#"{"pages":"1"}"#)] {
let out = cli().args(["run", "--sample", "--export", a, opt, val]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "{opt} after the last --export: {err}");
assert!(err.contains(opt) && err.contains("before"), "{err}");
}
assert!(!std::path::Path::new(a).exists(), "nothing is exported when the arguments are wrong");
let out = cli().args(["run", "--sample", "--scale", "0.5", "--export", a, "--scale", "0.25", "--export", b]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{err}");
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());
}

View File

@ -3,7 +3,6 @@ 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
@ -16,8 +15,6 @@ path = "src/main.rs"
[dependencies]
designcraft-engine = { workspace = true }
designcraft-format = { workspace = true }
form_urlencoded = "1.2"
designcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }

View File

@ -1,67 +0,0 @@
//! Pure browser startup and download metadata policies.
/// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false`
/// still enables sample startup, and repeated keys have the same presence semantics.
pub(crate) fn has_query_flag(query: &str, flag: &str) -> bool {
let query = query.strip_prefix('?').unwrap_or(query);
form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == flag)
}
pub(crate) fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => designcraft_format::MIME,
Some("idml") => "application/vnd.adobe.indesign-idml-package",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::{has_query_flag, mime_for};
#[test]
fn startup_flags_match_complete_decoded_keys() {
for flag in ["sample", "webgl"] {
for query in [format!("?{flag}"), format!("?{flag}=false"), format!("?{flag}=1&{flag}=0"), format!("?other=value&{flag}")] {
assert!(has_query_flag(&query, flag), "{query}");
}
for query in [
String::new(),
format!("?note={flag}"),
format!("?{flag}d=1"),
format!("?note=not_a_{flag}"),
"?note=%73ample%77ebgl".to_string(),
format!("?{flag}+extra=1"),
format!("?{flag}%26extra=1"),
format!("?{flag}%3Dextra=1"),
] {
assert!(!has_query_flag(&query, flag), "{query}");
}
}
assert!(has_query_flag("?webgl&sample", "sample"));
assert!(has_query_flag("?webgl&sample", "webgl"));
assert!(has_query_flag("?%73ample", "sample"));
assert!(has_query_flag("?%77ebgl", "webgl"));
assert!(!has_query_flag("?Sample&WEBGL", "sample"));
assert!(!has_query_flag("?Sample&WEBGL", "webgl"));
}
#[test]
fn download_mime_matches_native_package_and_existing_exports() {
for (name, expected) in [
("Quarterly.designcraft", designcraft_format::MIME),
("日本語.multi.DEsignCraft", designcraft_format::MIME),
("page.pdf", "application/pdf"),
("page.png", "image/png"),
("page.jpg", "image/jpeg"),
("page.JPEG", "image/jpeg"),
("document.idml", "application/vnd.adobe.indesign-idml-package"),
("unknown.bin", "application/octet-stream"),
] {
assert_eq!(mime_for(name), expected, "{name}");
}
}
}

View File

@ -12,13 +12,9 @@
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the
//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values
//! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it).
//! sample magazine on start.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod browser_policy;
#[cfg(target_arch = "wasm32")]
mod web;

View File

@ -1,8 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use super::browser_policy::{has_query_flag, mime_for};
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
@ -42,7 +41,7 @@ pub fn start() {
return;
};
let mut options = eframe::WebOptions::default();
if has_query_flag(&query(), "webgl")
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@ -57,7 +56,7 @@ pub fn start() {
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
if has_query_flag(&query(), "sample") {
if query().contains("sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
@ -88,14 +87,13 @@ impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
@ -105,8 +103,7 @@ impl eframe::App for WebShell {
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
@ -118,12 +115,12 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
open_async: Some(Box::new(move |request: ImportRequest| {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
let dialog = if purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "place" {
} else if purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS)
@ -133,7 +130,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes });
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
@ -176,3 +173,14 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => "application/json",
Some("idml") => "application/vnd.adobe.indesign-idml-package",
_ => "application/octet-stream",
}
}

View File

@ -2,7 +2,6 @@
name = "designcraft"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
default-run = "designcraft"
@ -15,7 +14,6 @@ designcraft-engine = { workspace = true }
designcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@ -25,13 +23,10 @@ winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
# The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs.
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 }

View File

@ -1,398 +0,0 @@
//! Which wgpu backend draws the window, and the fallback when one of them crashes at start-up.
//!
//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites the
//! interface, so every backend shows the same pixels and the one that never crashes is the right
//! one. wgpu's default backend set (`PRIMARY | GL`) enumerates Vulkan first, and on an AMD
//! hybrid-graphics laptop (a Radeon RX 6800M beside an integrated Radeon, two versions of
//! `amdvlk64.dll` loaded into the process) AMD's Vulkan driver faulted with an access violation
//! at the first swapchain present. A fault inside a driver can't be caught (the crash guard never
//! sees it), so the backend is chosen before the window exists: on Windows DirectX 12 first (the
//! backend browsers' WebGPU uses), then Vulkan, then OpenGL; on Linux Vulkan, then OpenGL; on
//! macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, `vulkan`, `gl`, `metal`, or a
//! comma-separated list — overrides the choice and turns the fallback off.
//!
//! The fallback: before the window is created, `gpu.json` in the settings directory records the
//! backend being tried; once a frame has been presented, that entry is cleared. An entry still
//! there at the next start means the previous start never got that far — the driver crashed, or
//! the app was killed meanwhile — so that backend joins `failed`, the next candidate is tried and
//! the status bar says so. When every candidate has failed the list starts over, and so does a
//! new DesignCraft version (drivers and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing.
use std::path::{Path, PathBuf};
use eframe::wgpu;
use serde::{Deserialize, Serialize};
/// The record's file name inside the settings directory.
pub const FILE: &str = "gpu.json";
/// A graphics backend wgpu can draw the window with.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Dx12,
Vulkan,
Metal,
Gl,
}
impl Backend {
/// The name the status bar and the log use.
pub fn name(self) -> &'static str {
match self {
Backend::Dx12 => "DirectX 12",
Backend::Vulkan => "Vulkan",
Backend::Metal => "Metal",
Backend::Gl => "OpenGL",
}
}
/// wgpu's bit for this backend.
pub fn bit(self) -> wgpu::Backends {
match self {
Backend::Dx12 => wgpu::Backends::DX12,
Backend::Vulkan => wgpu::Backends::VULKAN,
Backend::Metal => wgpu::Backends::METAL,
Backend::Gl => wgpu::Backends::GL,
}
}
/// The backend of the adapter wgpu picked; `None` for the web and no-op backends.
pub fn of(backend: wgpu::Backend) -> Option<Backend> {
match backend {
wgpu::Backend::Dx12 => Some(Backend::Dx12),
wgpu::Backend::Vulkan => Some(Backend::Vulkan),
wgpu::Backend::Metal => Some(Backend::Metal),
wgpu::Backend::Gl => Some(Backend::Gl),
_ => None,
}
}
}
/// The backends to try on this platform, best first.
pub fn candidates() -> &'static [Backend] {
if cfg!(windows) {
&[Backend::Dx12, Backend::Vulkan, Backend::Gl]
} else if cfg!(target_os = "macos") {
&[Backend::Metal]
} else {
&[Backend::Vulkan, Backend::Gl]
}
}
/// What `gpu.json` holds.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
/// The DesignCraft version that wrote the record; another version starts afresh.
#[serde(default)]
pub version: String,
/// The backend of a start that has not presented a frame yet.
#[serde(default)]
pub trying: Option<Backend>,
/// Backends whose start never presented a frame.
#[serde(default)]
pub failed: Vec<Backend>,
}
/// The backend for a start, from [`choose`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
pub backend: Backend,
/// The record to write before the window is created.
pub record: Record,
/// The backend the previous start never finished with, when this start moves away from it.
pub switched_from: Option<Backend>,
}
/// Pick the backend for this start from the candidates (best first) and the previous start's
/// record; `None` without candidates.
pub fn choose(candidates: &[Backend], previous: &Record, version: &str) -> Option<Choice> {
let first = *candidates.first()?;
let same_version = previous.version == version;
let mut failed: Vec<Backend> = if same_version { previous.failed.clone() } else { Vec::new() };
failed.retain(|b| candidates.contains(b));
let unfinished = previous.trying.filter(|_| same_version);
if let Some(b) = unfinished
&& !failed.contains(&b)
{
failed.push(b);
}
let backend = match candidates.iter().copied().find(|b| !failed.contains(b)) {
Some(b) => b,
// Every candidate has failed: start over rather than never starting.
None => {
failed.clear();
first
}
};
let switched_from = unfinished.filter(|b| *b != backend);
Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from })
}
/// The record in `path`; a missing or unreadable file is an empty record.
pub fn read(path: &Path) -> Record {
std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default()
}
/// Write `record` to `path` (creating the directory).
pub fn write(path: &Path, record: &Record) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?;
std::fs::write(path, bytes)
}
/// A start in progress: the backend chosen for it and the record that says so until a frame has
/// been presented.
#[derive(Debug)]
pub struct Startup {
pub backend: Backend,
pub switched_from: Option<Backend>,
/// `gpu.json`, when the record is persisted.
path: Option<PathBuf>,
record: Record,
}
impl Startup {
/// Choose the backend for this start and record it in `path` (nothing is recorded without
/// one). `None` when `WGPU_BACKEND` is set — wgpu follows it and the fallback is off — or the
/// platform has no candidates.
pub fn begin(path: Option<PathBuf>, version: &str) -> Option<Startup> {
if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) {
log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off");
return None;
}
Self::begin_unforced(path, version)
}
fn begin_unforced(path: Option<PathBuf>, version: &str) -> Option<Startup> {
let previous = path.as_deref().map(read).unwrap_or_default();
let choice = choose(candidates(), &previous, version)?;
if let Some(p) = &path
&& let Err(e) = write(p, &choice.record)
{
log::warn!("graphics: {}: {e}", p.display());
}
match choice.switched_from {
Some(from) => log::warn!(
"graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})",
from.name(),
choice.backend.name(),
choice.record.failed
),
None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
}
Some(Startup { backend: choice.backend, switched_from: choice.switched_from, path, record: choice.record })
}
/// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it
/// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers
/// (AMD's) crash while the instance is created (#167), before any fallback could help, so
/// there OpenGL is only ever tried as its own candidate.
pub fn backends(&self) -> wgpu::Backends {
let mut backends = self.backend.bit();
if !cfg!(windows) && !self.record.failed.contains(&Backend::Gl) {
backends |= wgpu::Backends::GL;
}
backends
}
/// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend
/// that is actually drawing.
pub fn started_with(&mut self, backend: Backend) {
if self.record.trying == Some(backend) {
return;
}
self.backend = backend;
self.record.trying = Some(backend);
self.save();
}
/// A frame has been presented (or the app exited normally): this start did not crash.
pub fn presented(&mut self) {
if self.record.trying.take().is_some() {
self.save();
}
}
/// The status-bar line for a start that moved away from a backend that crashed.
pub fn status_line(&self) -> Option<String> {
let from = self.switched_from?;
Some(format!(
"Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).",
from.name(),
self.backend.name(),
from.name()
))
}
fn save(&self) {
if let Some(p) = &self.path
&& let Err(e) = write(p, &self.record)
{
log::warn!("graphics: {}: {e}", p.display());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
const V: &str = "0.5.0";
const WIN: &[Backend] = &[Backend::Dx12, Backend::Vulkan, Backend::Gl];
fn temp_path(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d.join(FILE)
}
fn record(trying: Option<Backend>, failed: &[Backend]) -> Record {
Record { version: V.into(), trying, failed: failed.to_vec() }
}
#[test]
fn every_platform_has_candidates_without_duplicates() {
let c = candidates();
assert!(!c.is_empty());
for (i, b) in c.iter().enumerate() {
assert!(!c[..i].contains(b), "{b:?} twice in {c:?}");
}
if cfg!(windows) {
assert_eq!(c[0], Backend::Dx12, "DirectX 12 is the Windows default (AMD's Vulkan driver faulted at the first present)");
assert!(!c.contains(&Backend::Metal));
}
if cfg!(target_os = "macos") {
assert_eq!(c, &[Backend::Metal]);
}
}
#[test]
fn a_fresh_start_takes_the_first_candidate() {
let c = choose(WIN, &Record::default(), V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
assert_eq!(c.switched_from, None);
assert!(choose(&[], &Record::default(), V).is_none());
}
#[test]
fn a_start_that_never_presented_moves_to_the_next_candidate() {
let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V).unwrap();
assert_eq!(c.backend, Backend::Vulkan);
assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Dx12]));
assert_eq!(c.switched_from, Some(Backend::Dx12));
let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12]), V).unwrap();
assert_eq!(c.backend, Backend::Gl);
assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Vulkan]);
assert_eq!(c.switched_from, Some(Backend::Vulkan));
}
#[test]
fn a_finished_start_keeps_the_failed_list_and_switches_silently() {
// `trying` was cleared by a presented frame; the next start still avoids what failed.
let c = choose(WIN, &record(None, &[Backend::Dx12]), V).unwrap();
assert_eq!(c.backend, Backend::Vulkan);
assert_eq!(c.record.failed, &[Backend::Dx12]);
assert_eq!(c.switched_from, None);
// The same backend failing again is listed once.
let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V).unwrap();
assert_eq!(c.record.failed, &[Backend::Dx12]);
}
#[test]
fn when_every_candidate_failed_the_list_starts_over() {
let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12, Backend::Vulkan]), V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record.failed, &[]);
assert_eq!(c.switched_from, Some(Backend::Gl));
}
#[test]
fn another_version_or_platform_forgets_what_failed() {
let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] };
let c = choose(WIN, &old, V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
assert_eq!(c.switched_from, None);
// A backend that is no candidate here (a record copied from another machine) is dropped.
let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V).unwrap();
assert_eq!(c.backend, Backend::Gl);
assert_eq!(c.record.failed, &[Backend::Vulkan]);
}
#[test]
fn the_record_round_trips_and_garbage_reads_as_empty() {
let path = temp_path("roundtrip");
assert_eq!(read(&path), Record::default());
let r = record(Some(Backend::Vulkan), &[Backend::Dx12]);
write(&path, &r).expect("write");
assert_eq!(read(&path), r);
let text = std::fs::read_to_string(&path).expect("read");
assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}");
std::fs::write(&path, b"{not json").expect("garbage");
assert_eq!(read(&path), Record::default());
// Unknown fields and missing ones are tolerated.
std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial");
assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] });
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
}
#[test]
fn a_start_records_its_backend_until_a_frame_is_presented() {
let path = temp_path("startup");
let mut s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates");
let first = candidates()[0];
assert_eq!(s.backend, first);
assert_eq!(read(&path).trying, Some(first));
assert!(s.backends().contains(first.bit()));
// OpenGL is the in-process fallback, except on Windows where it is only its own candidate.
assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl);
assert_eq!(s.status_line(), None);
// wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one.
s.started_with(Backend::Gl);
assert_eq!(read(&path).trying, Some(Backend::Gl));
s.presented();
assert_eq!(read(&path), record(None, &[]));
s.presented();
assert_eq!(read(&path), record(None, &[]));
// The next start after one that never presented: switches, says so, and leaves OpenGL
// 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");
// 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}");
assert!(!s.backends().contains(wgpu::Backends::GL));
} else {
assert_eq!(s.switched_from, None);
}
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
}
#[test]
fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() {
let mut s = Startup::begin_unforced(None, V).expect("candidates");
s.started_with(Backend::Gl);
s.presented();
// A file where the directory should be: every write fails, nothing panics.
let blocked = temp_path("blocked");
let dir = blocked.parent().expect("dir");
std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp");
std::fs::write(dir, "a file where the directory should be").expect("block");
let mut s = Startup::begin_unforced(Some(blocked.clone()), V).expect("candidates");
s.started_with(Backend::Gl);
s.presented();
let _ = std::fs::remove_file(dir);
}
}

View File

@ -1,437 +0,0 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
//! report, a failed crash-recovery save, font folders that can't be read) vanished. A launch from
//! a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/designcraft.log` (the directory beside `ui.json` and `prefs.json`, see
//! `config_dir` in `main.rs`). Each start moves the previous log to `designcraft.1.log` (and that
//! one to `.2`), so the log of a run that crashed survives the next launch.
//!
//! Levels: `info` for DesignCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,designcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every
//! target that starts with it (`designcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "designcraft.log";
/// How many previous logs are kept (`designcraft.1.log` … `designcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,designcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("designcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn the_default_filter_shows_designcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("designcraft"), LevelFilter::Info);
assert_eq!(f.level_for("designcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,designcraft_render=trace,designcraft_render::tile=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("designcraft_render::raster"), LevelFilter::Trace);
assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
for spec in ["", ",,,", "=", "=debug", "designcraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,designcraft=DEBUG"] {
let f = Filter::parse(spec);
let _ = f.level_for("designcraft");
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,designcraft=DEBUG").level_for("designcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
// 2026-10-08T07:59:17.728Z
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap days, including the century rule (2000 is a leap year, 2100 is not).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4");
assert_eq!(std::fs::read_to_string(dir.join("designcraft.1.log")).expect("1"), "run 3");
assert_eq!(std::fs::read_to_string(dir.join("designcraft.2.log")).expect("2"), "run 2");
assert!(!dir.join("designcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Error, "main", "designcraft_engine::guard", "command `frame.create` panicked: boom"),
"2026-10-08T07:59:17.728Z ERROR [main] designcraft_engine::guard: command `frame.create` panicked: boom\n"
);
// A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "designcraft::prefs", "settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Debug, "designcraft", "below info");
let text = read(&path);
assert!(text.contains(" INFO [") && text.contains("designcraft::prefs: settings directory\n"), "{text}");
assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}");
assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("designcraft_render").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
let _ = std::fs::remove_file(&dir);
}
}

View File

@ -5,108 +5,63 @@
//! `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `designcraft_ui_egui::control` for the methods.
//!
//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window
//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod profile;
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Services};
struct App {
app: DesignApp,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
/// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`.
startup: Option<gpu::Startup>,
/// Frames begun so far.
frames: u32,
}
struct App(DesignApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The third call: two frames went through `ui`, so the first was presented and the driver
// survived it (a fault at the first present is what the fallback in `gpu` is for).
if self.frames >= 2
&& let Some(s) = self.startup.as_mut()
{
s.presented();
self.startup = None;
}
self.frames = self.frames.saturating_add(1);
#[cfg(target_os = "macos")]
{
if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
if self.1.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
}
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
if let Some(m) = &mut self.1 {
m.poll(&mut self.0);
}
}
self.app.logic(ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
self.0.ui(ui);
}
fn on_exit(&mut self) {
// A normal exit before the third frame is not a crash.
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);
save_prefs(&self.0);
}
}
/// 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).
fn no_prefs() -> bool {
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
}
fn prefs_path() -> Option<std::path::PathBuf> {
profile::config_dir().map(|b| b.join("ui.json"))
let base = 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"))
};
base.map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut DesignApp) {
if no_prefs() {
if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes)
&& let Ok(ui) = serde_json::from_slice::<designcraft_ui_egui::UiState>(&bytes)
{
app.ui = ui;
}
@ -120,7 +75,7 @@ fn load_prefs(app: &mut DesignApp) {
}
fn save_prefs(app: &DesignApp) {
if no_prefs() {
if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path() {
@ -209,8 +164,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]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
}
}
@ -228,28 +182,14 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
/// The control port from `--control <port>` or `DESIGNCRAFT_CONTROL_PORT` (`source`); a value that
/// is not a port is logged and ignored instead of silently starting no control channel.
fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
let value = value?;
let port = value.trim().parse().ok();
if port.is_none() {
log::warn!("{source}: {value:?} is not a port number; the control channel is off");
}
port
}
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok());
let mut control_port: Option<u16> = std::env::var("DESIGNCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = control_port_from("--control", args.next()),
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" => {
println!("designcraft {}", env!("CARGO_PKG_VERSION"));
@ -258,15 +198,6 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// 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, 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.
Err(e) => log::warn!("no log file: {e}"),
}
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("DesignCraft")
@ -282,14 +213,6 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// 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 { 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
{
create.instance_descriptor.backends = s.backends();
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file
// manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1
// keeps the native Wayland backend.
@ -300,37 +223,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,
Box::new(move |cc| {
let mut gpu = gpu;
if let Some(rs) = &cc.wgpu_render_state {
// What a bug report needs to know about the graphics driver.
let info = rs.adapter.get_info();
log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info);
if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) {
s.started_with(backend);
}
}
let mut session = Session::new();
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
session.recovery_dir = profile::recovery_dir();
session.recovery_dir = designcraft_engine::recovery::default_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) {
app.status(line);
}
// Recovered documents matter more than the graphics note; both are in the log.
if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) {
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
}
@ -344,33 +245,21 @@ fn main() -> eframe::Result {
let _ = app.run("file.newSample", serde_json::json!({}));
}
for f in files {
if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
eprintln!("designcraft: {f}: {e}");
}
}
Ok(Box::new(App {
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
menu: None,
startup: gpu,
frames: 0,
}))
None,
)))
}),
)
}
#[cfg(test)]
mod tests {
#[test]
fn control_port_values_that_are_not_ports_are_ignored() {
assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979));
assert_eq!(super::control_port_from("--control", Some(" 7979 ".into())), Some(7979));
assert_eq!(super::control_port_from("--control", None), None);
for bad in ["", "abc", "-1", "65536", "99999999999999999999"] {
assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}");
}
}
#[test]
fn data_merge_open_dialog_lists_table_extensions() {
let filters = super::open_filters("dataMerge");
@ -386,168 +275,3 @@ 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, &current.selection.items, current.history.undo.len(), current.history.redo.len()),
(uid, revision, saved_revision, &selection, undo, redo)
);
assert!(std::sync::Arc::ptr_eq(&current.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();
}
}
}

View File

@ -40,8 +40,6 @@ 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,
@ -52,11 +50,10 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&quit,
&PredefinedMenuItem::quit(None),
]);
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);
@ -75,10 +72,10 @@ impl NativeMenu {
Self { _menu: menu, items, last_refresh: 0.0 }
}
pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
pub fn poll(&mut self, app: &mut DesignApp) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
menus::activate_native(app, ctx, &cmd.clone(), &params.clone());
designcraft_ui_egui::menus::activate(app, &cmd.clone(), &params.clone());
}
}
let now = designcraft_ui_egui::now_ms();
@ -87,7 +84,7 @@ impl NativeMenu {
}
self.last_refresh = now;
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
let en = menus::menu_enabled(app, cmd);
match h {
Handle::Plain(i) => i.set_enabled(en),
Handle::Check(c) => {

View File

@ -1,108 +0,0 @@
//! Native settings and recovery paths, including isolated automation profiles.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn config_dir() -> Option<PathBuf> {
config_dir_with(std::env::consts::OS, |key| std::env::var_os(key))
}
fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
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<OsString> {
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);
}
}

Binary file not shown.

View File

@ -21,12 +21,3 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.emanuelebonaventura]
display_name = "insanityxoff"
[people.moppeeer]
sync_github_name = true
[people.thaynej]
real_name = "Jeffrey Thayne"

View File

@ -104,12 +104,9 @@ 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, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) {
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
s.midpoint = m;
}
}
pub fn sort(&mut self) {
@ -222,24 +219,8 @@ 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::<Vec<_>>(), [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}");
}
}
}

View File

@ -10,7 +10,6 @@ workspace = true
[dependencies]
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
@ -19,7 +18,6 @@ designcraft-fonts = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
miniz_oxide = "0.8"
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }

View File

@ -148,46 +148,24 @@ struct Item {
hang: f64,
/// Auto hyphenation points: index of the break opportunity before the word (or `NONE`).
zone_from: usize,
/// An emergency break inside a word wider than the measure.
emergency: bool,
}
const NONE: usize = usize::MAX;
impl Item {
fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item {
Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE, emergency: false }
Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE }
}
fn glue(w: f64, st: f64, sh: f64) -> Item {
Item {
kind: Kind::Glue,
w,
st: [st, 0.0, 0.0],
sh: [sh, 0.0, 0.0],
p: 0.0,
flagged: false,
auto: false,
hang: 0.0,
zone_from: NONE,
emergency: false,
}
Item { kind: Kind::Glue, w, st: [st, 0.0, 0.0], sh: [sh, 0.0, 0.0], p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE }
}
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE, emergency: false }
Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE }
}
}
const INF: f64 = 10000.0;
/// A line whose natural width exceeds its measure by no more than this (points) still fits: frame
/// widths and glyph advances are rounded, so a line that exactly fills the measure can come out a
/// hair too wide.
const FIT_EPS: f64 = 0.01;
/// Penalty for an emergency break inside a word wider than the measure: high, so a paragraph
/// takes no more of them than it must, but below [`INF`] so they are legal.
const EMERGENCY_PENALTY: f64 = 5000.0;
/// Does the glyph force a line end after it?
/// A line may break between `a` and `b` in CJK text (any break next to an ideograph, kana or
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
@ -230,7 +208,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
c == FORCED_LINE_BREAK || is_break_char(c)
matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK)
}
/// Optical margin protrusion of `c` as fractions of its advance: (left edge, right edge).
@ -296,8 +274,7 @@ fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, p: Item, g: usize, sp: &
}
/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens).
/// `emergency_after[i]` allows an emergency break after glyph `i` (see [`overlong_words`]).
fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
let mut it: Vec<Item> = Vec::with_capacity(glyphs.len() * 2 + 2);
let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2);
let n = glyphs.len();
@ -375,10 +352,6 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
p.hang = hw * hang('-').1;
}
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if emergency_after.get(i) == Some(&true) {
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
p.emergency = true;
push_inword(&mut it, &mut ig, p, i + 1, sp);
}
}
}
@ -411,57 +384,13 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
if glyphs.is_empty() {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
let (mut items, mut ig) = items(glyphs, hyph_after, &[], sp);
let narrowest = (0..=glyphs.len().min(64)).map(width).fold(f64::INFINITY, f64::min).max(1.0);
if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, narrowest) {
(items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp);
}
let (items, ig) = items(glyphs, hyph_after, sp);
for tolerance in [2.5, 12.0, f64::INFINITY] {
if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) {
return b;
}
}
greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0)
}
/// Glyphs after which a word wider than `measure` may take an emergency break, or `None` when every
/// word fits. A word is a run of boxes between two break opportunities (so a word with hyphenation
/// points only gets emergency breaks where a part between them is still too wide), measured at
/// its narrowest (fully shrunk) width. Emergency breaks respect no-break text and kinsoku.
fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) -> Option<Vec<bool>> {
let mut out: Option<Vec<bool>> = None;
let mut seg: Option<(usize, usize)> = None;
let mut w = 0.0;
for (k, it) in items.iter().enumerate() {
match it.kind {
Kind::Box => {
let g = ig.get(k).copied().unwrap_or(0);
seg = Some(seg.map_or((g, g), |(a, _)| (a, g)));
w += it.w - it.sh.iter().sum::<f64>();
}
Kind::Penalty if it.p >= INF => {}
Kind::Glue | Kind::Penalty => {
if let Some((a, b)) = seg.take()
&& w > measure + FIT_EPS
{
let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]);
for i in a..b {
let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue };
let ok = !g.no_break
&& g.break_after != Some(false)
&& next.ch != SOFT_HYPHEN
&& !is_forced(next.ch)
&& kinsoku_allows(g.ch, next.ch);
if let Some(m) = marks.get_mut(i) {
*m = ok;
}
}
}
w = 0.0;
}
}
}
out
greedy(glyphs, hyph_after, sp, width)
}
/// Upper bound on the hyphen-count states tracked per breakpoint.
@ -525,15 +454,7 @@ fn kp_pass(
y[t] = sy[b][t] - n.ty[t];
z[t] = sz[b][t] - n.tz[t];
}
// A line that overruns its measure by a rounding error fits exactly.
let d = target - l;
let d = if d < 0.0 && d > -FIT_EPS { 0.0 } else { d };
let (mut r, feasible) = tiered_ratio(d, y, z);
if it.emergency && sp.justify && r > 0.0 {
// A piece of an overlong word usually has no elastic material: rate it by the
// space it leaves, so the fullest piece wins (the break at the last glyph that fits).
r = d / target;
}
let (r, feasible) = tiered_ratio(target - l, y, z);
if feasible && !forced {
keep.push(a);
} else {
@ -692,9 +613,8 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
if out.is_empty() {
out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true });
}
// Lines produced past the paragraph end are dropped, except the empty line after a trailing
// forced line break (a trailing column, frame or page break ends the paragraph).
let trailing_forced = glyphs.last().is_some_and(|g| g.ch == designcraft_doc::story::FORCED_LINE_BREAK);
// Lines produced past the paragraph end (e.g. a trailing forced break) are dropped.
let trailing_forced = glyphs.last().is_some_and(|g| is_forced(g.ch));
out.retain(|b| b.start < n || (b.start == n && trailing_forced));
out
}
@ -708,16 +628,7 @@ fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
}
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
///
/// `tab(line, x, i)` is the advance of the tab glyph `i` when it starts `x` from the start of
/// line `line`: tabs have no width until they reach their stop, which depends on where they sit.
pub fn greedy(
glyphs: &[Glyph],
hyph_after: &[bool],
sp: &Spacing,
width: &dyn Fn(usize) -> f64,
tab: &dyn Fn(usize, f64, usize) -> f64,
) -> Vec<Break> {
pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::new();
let mut start = 0;
@ -753,8 +664,7 @@ pub fn greedy(
continue;
}
let hang_r = right_hang(g, sp);
let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv };
if x + adv - hang_r > w + shrink + FIT_EPS
if x + g.adv - hang_r > w + shrink
&& i > start
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
{
@ -769,7 +679,7 @@ pub fn greedy(
});
break;
}
x += adv;
x += g.adv;
if sp.justify {
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
}
@ -780,7 +690,7 @@ pub fn greedy(
last_ok = Some((i + 1, false));
} else if hyph_after[i] && may_hyphenate(hyphens) {
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
if x + hy <= w + shrink + FIT_EPS {
if x + hy <= w + shrink {
last_ok = Some((i + 1, true));
}
}
@ -805,10 +715,9 @@ pub fn greedy(
start = next;
line += 1;
}
// A forced line break as the very last glyph leaves an empty final line (InDesign shows it);
// a column, frame or page break there ends the paragraph.
// A forced break as the very last glyph leaves an empty final line (InDesign shows it).
if let Some(last) = glyphs.last()
&& last.ch == designcraft_doc::story::FORCED_LINE_BREAK
&& is_forced(last.ch)
{
out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true });
}

View File

@ -13,8 +13,7 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode};
use crate::vars::RunningIndex;
use crate::{ComposeOptions, ComposedStory};
/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`).
type Key = (StoryId, Option<usize>);
type Key = (StoryId, Option<String>);
struct Entry {
sig: Vec<usize>,
@ -56,23 +55,11 @@ impl Cache {
Self::default()
}
/// 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<usize>) -> Arc<ComposedStory> {
/// The composed story, from cache or freshly composed.
pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
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);
// 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 key = (sid, page_name.map(str::to_string));
let with_vars = crate::vars::has_vars(story);
let running = with_vars && crate::vars::has_running(doc, story);
if with_vars {
@ -138,20 +125,23 @@ impl Cache {
return e.out.clone();
}
}
// Parent-page items are composed per page: markers show its name, variables need its index.
let page = page.filter(|_| with_vars);
// 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,
};
let running = running.then(|| self.running_index(doc));
let out = Arc::new(crate::compose_story(
doc,
sid,
&ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&ComposeOptions { page_name: page_name.map(str::to_string), 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, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
let replaced = g.0.insert(key.clone(), 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

View File

@ -1,9 +1,6 @@
//! 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; 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 hyphenation.
//!
//! English lookup order for each word:
//! Lookup order for each word:
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
//! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple
//! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem;
@ -64,33 +61,6 @@ impl Default for Limits {
}
}
/// The hyphenator for a run of text.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Lang {
English,
Patterns(hypher::Lang),
}
impl Lang {
/// The hyphenator for a language name or locale code (see [`designcraft_doc::language_tag`]).
/// None when the language has no bundled hyphenation.
pub fn for_language(language: &str) -> Option<Lang> {
if language.starts_with("English") {
return Some(Lang::English);
}
let tag = designcraft_doc::language_tag(language)?;
let sub = designcraft_doc::language_subtag(tag);
if sub == "en" {
return Some(Lang::English);
}
let b = sub.as_bytes();
if b.len() != 2 {
return None;
}
hypher::Lang::from_iso([b[0], b[1]]).map(Lang::Patterns)
}
}
/// Where a word's break points came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
@ -109,25 +79,11 @@ fn is_word_char(c: char) -> bool {
}
/// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`).
fn core_breaks(core: &[char], lang: Lang) -> (Vec<usize>, Source) {
fn core_breaks(core: &[char]) -> (Vec<usize>, Source) {
let n = core.len();
if n < 2 {
return (vec![], Source::None);
}
if let Lang::Patterns(l) = lang {
if core.contains(&'\'') {
return (vec![], Source::None);
}
let s: String = core.iter().collect();
let mut out = Vec::new();
let mut at = 0;
let syl: Vec<&str> = hypher::hyphenate_bounded(&s, l, 1, 1).collect();
for part in &syl[..syl.len().saturating_sub(1)] {
at += part.chars().count();
out.push(at);
}
return (out, Source::Patterns);
}
let dict = Dictionary::en_us();
let s: String = core.iter().collect();
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
@ -156,11 +112,6 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec<usize>, Source) {
/// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is
/// hyphenated separately). No break is ever placed next to an apostrophe or non-letter.
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
word_breaks_in(word, Lang::English)
}
/// [`word_breaks`] for a language.
pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec<usize>, Source) {
let chars: Vec<char> = word.chars().collect();
let mut out = Vec::new();
let mut src = Source::None;
@ -182,7 +133,7 @@ pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec<usize>, Source) {
b -= 1;
}
let core: Vec<char> = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect();
let (pts, s) = core_breaks(&core, lang);
let (pts, s) = core_breaks(&core);
if src == Source::None {
src = s;
}
@ -195,13 +146,8 @@ pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec<usize>, Source) {
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with
/// the paragraph limits applied to each letter run.
pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
hyphen_points_in(word, lim, Lang::English)
}
/// [`hyphen_points`] for a language.
pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
let chars: Vec<char> = word.chars().collect();
let (pts, _) = word_breaks_in(word, lang);
let (pts, _) = word_breaks(word);
if pts.is_empty() {
return pts;
}
@ -233,8 +179,9 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
if letters < lim.min_word.max(2) {
return false;
}
// Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate
// Capitalized Words off keeps both, acronyms included, whole.
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
return false;
}
if !lim.capitalized && run[0].is_uppercase() {
return false;
}
@ -332,10 +279,7 @@ mod tests {
#[test]
fn case_apostrophes_and_punctuation() {
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
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"), "TYPOGRAPHY");
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");
@ -375,42 +319,6 @@ mod tests {
assert!(hyphen_points("typography", &lim).is_empty());
}
#[test]
fn spanish_uses_the_bezos_patterns() {
let es = Lang::for_language("Spanish").unwrap();
assert_eq!(es, Lang::for_language("es_ES").unwrap());
let h = |w: &str| {
let pts = hyphen_points_in(w, &Limits::default(), es);
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
};
assert_eq!(h("transición"), "tran-si-ción");
assert_eq!(h("pingüino"), "pin-güino");
assert_eq!(h("Construcción"), "Cons-truc-ción");
assert_eq!(h("desesperación"), "de-ses-pe-ra-ción");
assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole");
assert_eq!(h("calle"), "ca-lle", "ll stays whole");
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::<String>()
};
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<char> = "happen".chars().collect();

File diff suppressed because it is too large Load Diff

View File

@ -197,15 +197,10 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
hit_note_with(cs, fi, p, crate::hit)
}
/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]).
pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, hit(&n.text, 0, local).unwrap_or(0)))
Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:

View File

@ -22,15 +22,12 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK,
NestedUntil::EmSpace => c == '\u{2003}',
NestedUntil::EnSpace => c == '\u{2002}',
// Counted by position, not by character (see `overlays`).
NestedUntil::Dropcap => false,
NestedUntil::Chars(s) => s.contains(c),
}
}
/// Character-style ranges for the paragraph `text[range]`, in application order (later wins).
/// The paragraph's drop cap ends at `drop_end` (`range.start` without one).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> {
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
let para = &text[range.clone()];
// The paragraph's own end mark isn't styled.
@ -40,22 +37,17 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if pos >= end {
break;
}
let mut count = 0;
let mut stop = end;
if ns.until == NestedUntil::Dropcap {
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
} else {
let mut count = 0;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
}
}
@ -92,21 +84,14 @@ mod tests {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until };
// Through the first ':' then up to the second word end.
let o =
overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0);
let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]);
assert_eq!(&text[o[0].0.clone()], "Q:");
assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word");
// Digits by GREP.
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]);
let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect();
assert_eq!(got, ["42", "7"]);
// Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing.
let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap);
let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2);
assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what "));
assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: ");
assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty());
// A bad pattern is ignored.
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty());
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty());
}
}

View File

@ -28,14 +28,9 @@ pub struct Glyph {
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
/// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent
/// first baseline offset.
pub typo_ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
/// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
/// offsets.
pub cap: f64,
pub xh: f64,
pub size: f64,
@ -69,9 +64,6 @@ pub struct Glyph {
/// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung
/// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]).
pub upright: bool,
/// Part of the bullet or number the paragraph's list adds: not the line's own text, so it
/// doesn't raise the Ascent first baseline.
pub list_label: bool,
}
impl Glyph {
@ -170,13 +162,6 @@ impl StyleTable<'_> {
ruby: (!p.ruby.is_empty()).then(|| p.ruby.clone()),
kenten: p.kenten,
kenten_character: p.kenten_character.clone(),
warichu: p.warichu,
warichu_lines: p.warichu_lines,
warichu_size: p.warichu_size,
warichu_line_spacing: p.warichu_line_spacing,
warichu_align: p.warichu_alignment,
warichu_chars_before: p.warichu_chars_before_break,
warichu_chars_after: p.warichu_chars_after_break,
};
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
return i as u32;
@ -193,8 +178,7 @@ impl StyleTable<'_> {
}
}
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
/// one) and takes the character style `drop.1` ("" = none).
/// Resolve and shape one paragraph.
pub(crate) fn shape_para(
db: &ScopedFonts<'_>,
styles: &Styles,
@ -208,37 +192,13 @@ pub(crate) fn shape_para(
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
lines: &[(std::ops::Range<usize>, String)],
drop: (usize, &str),
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
// (later overlays win).
// Nested line styles lie under nested and GREP styles (later overlays win).
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
// The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML
// stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a
// missing entry. Either way the nested styles after it start after the drop cap.
let set = Some(drop.1).filter(|s| !s.is_empty());
let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE);
let with_cap: Vec<designcraft_doc::NestedStyle>;
let nested = match set {
Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect();
&with_cap[..]
}
Some(s) if cap_style.is_some() && !nested.is_empty() => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect();
&with_cap[..]
}
_ => nested,
};
if !nested.is_empty() || !grep.is_empty() {
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
}
if let Some(s) = cap_style
&& drop.0 > range.start
{
overlays.push((range.start..drop.0, s.to_string()));
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep));
}
// Each run, cut where nested / GREP styles start and end.
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
@ -306,7 +266,7 @@ pub(crate) fn shape_para(
}
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
g.ch = HIDDEN;
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
continue;
@ -585,8 +545,6 @@ fn shape_run_raw(
| story::COLUMN_BREAK
| story::FRAME_BREAK
| story::PAGE_BREAK
| story::ODD_PAGE_BREAK
| story::EVEN_PAGE_BREAK
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
| story::TABLE_ANCHOR
@ -630,18 +588,10 @@ fn shape_run_raw(
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.typo_ascent = g.typo_ascent.max(o.h + y);
g.cap = g.cap.max(o.h + y);
g.xh = g.xh.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => {
g.ascent += o.h + o.space;
g.typo_ascent += o.h + o.space;
g.cap += o.h + o.space;
g.xh += o.h + o.space;
}
None => g.ascent += o.h + o.space,
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
@ -812,7 +762,6 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -838,7 +787,6 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
shaping_rtl: false,
lang: designcraft_doc::language_tag(&p.language),
upright: false,
list_label: false,
}
}
@ -932,7 +880,6 @@ fn shape_segment(
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -962,7 +909,6 @@ fn shape_segment(
shaping_rtl: sg.rtl,
lang,
upright: up,
list_label: false,
});
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,266 +0,0 @@
//! Warichu (割り注): an inline note set in two or more smaller lines inside one parent em.
//!
//! Placement happens after the line is broken, in the composer's along/across space (`x` runs
//! along the line). The run gets shorter and the rest of the line closes up. Characters that did
//! not fit the line stay on the next line. Right-to-left and tate-chu-yoko runs are left alone.
use designcraft_doc::cjk::WarichuAlignment;
use crate::{PlacedGlyph, RunStyle};
pub(crate) fn place(styles: &[RunStyle], line: &mut [PlacedGlyph]) -> f64 {
if !line.iter().any(|g| is_warichu(styles, g)) {
return 0.0;
}
let mut delta = 0.0;
let mut i = 0;
while i < line.len() {
if !is_warichu(styles, &line[i]) {
i += 1;
continue;
}
let Some(key) = key(styles, &line[i]) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < line.len() {
let g = &line[j];
if g.len == 0 {
j += 1;
continue;
}
if is_warichu(styles, g) && key_of(styles, g) == Some(key) {
j += 1;
continue;
}
break;
}
let members: Vec<usize> = (i..j).filter(|&k| is_warichu(styles, &line[k])).collect();
if members.is_empty() || members.iter().any(|&k| line[k].rtl || line[k].tcy.is_some()) {
i = j;
continue;
}
delta += layout(styles, line, &members);
i = j;
}
delta
}
fn style<'a>(styles: &'a [RunStyle], g: &PlacedGlyph) -> Option<&'a RunStyle> {
styles.get(g.style as usize)
}
fn is_warichu(styles: &[RunStyle], g: &PlacedGlyph) -> bool {
g.len > 0 && g.visible && style(styles, g).is_some_and(|s| s.warichu)
}
type Key = (u32, u64, u64, WarichuAlignment, u32, u32);
fn key(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> {
let s = style(styles, g)?;
Some((
s.warichu_lines,
s.warichu_size.to_bits(),
s.warichu_line_spacing.to_bits(),
s.warichu_align,
s.warichu_chars_before,
s.warichu_chars_after,
))
}
fn key_of(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> {
is_warichu(styles, g).then(|| key(styles, g)).flatten()
}
/// How many small lines `len` glyphs can fill. The first line keeps at least `before` glyphs and
/// the last at least `after`; every other line keeps one. Fewer lines than `want` when the run is
/// too short for that break.
fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
if len == 0 {
return 1;
}
let mut n = want.clamp(1, 16).min(len);
let before = before.max(1);
let after = after.max(1);
while n >= 2 {
if before + after + n - 2 <= len {
return n;
}
n -= 1;
}
1
}
/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their
/// break minimums. One line when no split satisfies them.
fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> {
let m = advs.len();
if n <= 1 || m == 0 {
return vec![m];
}
let before = before.clamp(1, m);
let after = after.clamp(1, m);
let mut pref = vec![0.0_f64; m + 1];
for (i, a) in advs.iter().enumerate() {
pref[i + 1] = pref[i] + *a;
}
let mut best = vec![vec![f64::INFINITY; m + 1]; n + 1];
let mut take = vec![vec![0_usize; m + 1]; n + 1];
for s in 0..m {
if m - s >= after {
best[1][s] = pref[m] - pref[s];
take[1][s] = m - s;
}
}
for lines in 2..=n {
for s in 0..m {
let min_t = if lines == n { before } else { 1 };
let min_leave = after + lines - 2;
if s + min_t + min_leave > m {
continue;
}
let max_t = m - s - min_leave;
let mut local = f64::INFINITY;
let mut best_t = min_t;
for t in min_t..=max_t {
let rest = best[lines - 1][s + t];
if !rest.is_finite() {
continue;
}
let mx = (pref[s + t] - pref[s]).max(rest);
if mx < local {
local = mx;
best_t = t;
}
}
best[lines][s] = local;
take[lines][s] = best_t;
}
}
if !best[n][0].is_finite() {
return vec![m];
}
let mut counts = Vec::with_capacity(n);
let mut s = 0_usize;
for lines in (1..=n).rev() {
let t = take[lines][s];
if t == 0 || s + t > m {
return vec![m];
}
counts.push(t);
s += t;
}
if s != m { vec![m] } else { counts }
}
fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) {
for (i, &k) in members.iter().enumerate() {
let Some((y, adv, sx, sy)) = saved.get(i).copied() else { continue };
let Some(g) = line.get_mut(k) else { continue };
g.y = y;
g.adv = adv;
g.sx = sx;
g.sy = sy;
}
}
/// Scale `members` and stack them in `lines`. Returns how far the rest of the line moves.
fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f64 {
let Some(st) = members.first().and_then(|&k| style(styles, &line[k])) else {
return 0.0;
};
let before = st.warichu_chars_before as usize;
let after = st.warichu_chars_after as usize;
let n = line_count(members.len(), st.warichu_lines as usize, before, after);
let scale = (st.warichu_size / 100.0).clamp(0.05, 4.0);
let old_x0 = members.iter().map(|&k| line[k].x).fold(f64::INFINITY, f64::min);
let old_right = members.iter().map(|&k| line[k].x + line[k].adv).fold(f64::NEG_INFINITY, f64::max);
let old_span = old_right - old_x0;
if !old_span.is_finite() || old_span <= 0.0 {
return 0.0;
}
let saved: Vec<(f64, f64, f64, f64)> = members
.iter()
.map(|&k| {
let g = &line[k];
(g.y, g.adv, g.sx, g.sy)
})
.collect();
let old_xs: Vec<f64> = members.iter().map(|&k| line[k].x).collect();
for &k in members {
line[k].sx *= scale;
line[k].sy *= scale;
line[k].adv *= scale;
line[k].y *= scale;
}
let scaled: Vec<f64> = members.iter().map(|&k| line[k].adv).collect();
if scaled.iter().any(|a| !a.is_finite()) {
restore(line, members, &saved);
return 0.0;
}
let counts = if n <= 1 { vec![members.len()] } else { split_even(&scaled, n, before.max(1), after.max(1)) };
let mut widths = Vec::with_capacity(counts.len());
let mut off = 0_usize;
for &c in &counts {
if off + c > members.len() {
restore(line, members, &saved);
return 0.0;
}
let w: f64 = members[off..off + c].iter().map(|&k| line[k].adv).sum();
widths.push(w);
off += c;
}
let block = widths.iter().copied().fold(0.0_f64, f64::max);
if !block.is_finite() || off != members.len() {
restore(line, members, &saved);
return 0.0;
}
let rows = counts.len().max(1);
let small = st.size * scale;
// 0 is the default (one small em). Any other value, including a negative one, is added to
// that em and then clamped so the rows cannot swap order.
let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) };
let mut off = 0_usize;
for (li, &c) in counts.iter().enumerate() {
let w = widths[li];
let (start_x, extra) = match st.warichu_align {
WarichuAlignment::Left => (0.0, 0.0),
WarichuAlignment::Right => ((block - w).max(0.0), 0.0),
WarichuAlignment::Justify if c > 1 && block > w => (0.0, (block - w) / (c - 1) as f64),
WarichuAlignment::Auto | WarichuAlignment::Center | WarichuAlignment::Justify => ((block - w) / 2.0, 0.0),
};
let y_off = (li as f64 - (rows as f64 - 1.0) / 2.0) * gap;
let mut x = old_x0 + start_x;
for &k in &members[off..off + c] {
line[k].x = x;
line[k].y += y_off;
x += line[k].adv + extra;
}
off += c;
}
let delta = block - old_span;
let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect();
placed.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut member_at = vec![false; line.len()];
for &k in members {
if let Some(slot) = member_at.get_mut(k) {
*slot = true;
}
}
for (k, g) in line.iter_mut().enumerate() {
if member_at.get(k).copied().unwrap_or(false) {
continue;
}
if g.x >= old_right - 1e-6 {
g.x += delta;
} else if g.x >= old_x0 - 1e-6
&& let Some(anchor) = placed.iter().rev().find(|p| p.0 <= g.x + 1e-6)
{
// A mark drawn with the run (a tab leader is not: it sits before old_x0) keeps its
// offset from the glyph it was attached to, in the scaled line.
g.x = anchor.1 + (g.x - anchor.0) * scale;
}
}
delta
}

View File

@ -1,351 +0,0 @@
//! 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<AssetId, Arc<Asset>>, asset: &Asset) -> Option<AssetId> {
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<AssetId, Arc<Asset>>, 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<AssetId> {
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<AssetId, AssetId>) {
if !map.is_empty() {
remap_story(st, map, 0);
}
}
fn item_uses(it: &Item, map: &HashMap<AssetId, AssetId>) -> bool {
let mut hit = false;
item_refs(it, &mut |a| hit |= map.contains_key(&a), 0);
hit
}
fn story_uses(st: &Story, map: &HashMap<AssetId, AssetId>) -> 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<AssetId, AssetId>) {
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<Item>, map: &HashMap<AssetId, AssetId>, 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<AssetId, AssetId>, 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<AssetId, AssetId>, 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<AssetId> {
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<AssetId> {
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<AssetId, AssetId>) {
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<Asset>>> = 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<_>>(), 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)]);
}
}

View File

@ -431,8 +431,6 @@ pub enum NestedUntil {
ForcedLineBreak,
EmSpace,
EnSpace,
/// The paragraph's drop cap characters.
Dropcap,
/// Any of these characters.
Chars(String),
}
@ -469,18 +467,6 @@ impl Default for NestedStyle {
}
}
impl NestedStyle {
/// The drop cap's character style as a nested style: `style` through 1 drop cap.
pub fn drop_cap(style: &str) -> Self {
NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap }
}
/// Is this the form a drop cap's character style takes at the start of a nested style list?
pub fn is_drop_cap(&self) -> bool {
self.until == NestedUntil::Dropcap && self.through && self.count == 1
}
}
/// A character style applied to every match of a regular expression in the paragraph.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@ -544,16 +530,12 @@ pub struct TabStop {
pub align_on: String,
}
/// The colour of a paragraph rule that follows the paragraph's text colour.
pub const TEXT_COLOR: &str = "Text Color";
/// A paragraph rule (Rule Above / Rule Below).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Rule {
pub on: bool,
pub weight: f64,
/// A swatch, or [`TEXT_COLOR`].
pub color: String,
pub tint: f32,
/// Width: column (true) or text (false).
@ -565,7 +547,7 @@ pub struct Rule {
impl Default for Rule {
fn default() -> Self {
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
}
}
@ -589,33 +571,15 @@ pub enum NumberStyle {
LowerLetters,
/// 01, 02, …
ArabicLeadingZero,
/// 001, 002, …
ArabicThreeDigits,
/// 0001, 0002, …
ArabicFourDigits,
/// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes.
Symbols,
}
/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman
/// numerals, letters, symbols), so a hostile start number can't build a huge label.
const MAX_REPEATED_FORM: u32 = 3999;
impl NumberStyle {
pub fn format(self, n: u32) -> String {
if n > MAX_REPEATED_FORM
&& matches!(
self,
NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols
)
{
return n.to_string();
}
match self {
NumberStyle::Arabic => n.to_string(),
NumberStyle::ArabicLeadingZero => format!("{n:02}"),
NumberStyle::ArabicThreeDigits => format!("{n:03}"),
NumberStyle::ArabicFourDigits => format!("{n:04}"),
NumberStyle::UpperRoman => roman(n),
NumberStyle::LowerRoman => roman(n).to_lowercase(),
NumberStyle::UpperLetters => letters(n),
@ -629,58 +593,6 @@ impl NumberStyle {
}
}
/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …),
/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no
/// meaning in a label are dropped; a label never holds a paragraph break.
pub fn expand_list_text(s: &str, number: &str) -> String {
let mut out = String::with_capacity(s.len() + number.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c != '^' {
if c != '\n' && c != '\r' {
out.push(c);
}
continue;
}
let Some(m) = it.next() else {
out.push('^');
break;
};
let r = match m {
'#' => {
out.push_str(number);
continue;
}
'^' => '^',
't' => '\t',
'y' => crate::story::RIGHT_INDENT_TAB,
'i' => crate::story::INDENT_HERE,
'n' => crate::story::FORCED_LINE_BREAK,
'm' => '\u{2003}',
'>' => '\u{2002}',
'f' => '\u{2001}',
'|' => '\u{200A}',
'<' => '\u{2009}',
'/' => '\u{2007}',
'.' => '\u{2008}',
's' => '\u{A0}',
'S' => '\u{202F}',
'_' => '\u{2014}',
'=' => '\u{2013}',
'~' => '\u{2011}',
'{' => '\u{201C}',
'}' => '\u{201D}',
'[' => '\u{2018}',
']' => '\u{2019}',
'"' => '"',
'\'' => '\'',
_ => continue,
};
out.push(r);
}
out
}
fn roman(mut n: u32) -> String {
if n == 0 {
return "0".into();
@ -780,18 +692,6 @@ attr_set! {
ruby: String = String::new(),
/// Kenten: an emphasis dot above each character.
kenten: bool = false,
/// Warichu: the run is set in smaller lines stacked inside one line height.
warichu: bool = false,
/// How many warichu lines (InDesign's minimum is 2).
warichu_lines: u32 = 2,
/// Warichu size as a percentage of the parent size.
warichu_size: f64 = 50.0,
/// Extra points between warichu baselines. 0 is automatic (one small em).
warichu_line_spacing: f64 = 0.0,
warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto,
/// Minimum characters on a warichu line before a break, and on the line after it.
warichu_chars_before_break: u32 = 1,
warichu_chars_after_break: u32 = 1,
/// Digits (World-Ready): how 0–9 are drawn.
digits: Digits = Digits::Default,
character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default,
@ -827,17 +727,8 @@ attr_set! {
last_line_indent: f64 = 0.0,
space_before: f64 = 0.0,
space_after: f64 = 0.0,
/// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never
/// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the
/// character style `drop_cap_style` ("" or `[None]`: the text's own formatting).
drop_cap_lines: u32 = 0,
drop_cap_chars: u32 = 0,
drop_cap_style: String = String::new(),
/// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side
/// bearing is taken off), and the lines beside it start where its advance ends.
drop_cap_align_left: bool = true,
/// Scale for Descenders (kept and round-tripped; doesn't change the layout).
drop_cap_scale_descenders: bool = false,
/// Nested styles, in order from the paragraph start.
nested_styles: Vec<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
@ -882,15 +773,11 @@ attr_set! {
// Keeps
keep_with_next: u32 = 0,
keep_lines_together: bool = false,
/// All lines in paragraph (true) or at start/end of paragraph (false).
keep_all_lines: bool = false,
keep_all_lines: bool = true,
keep_first: u32 = 2,
keep_last: u32 = 2,
start_paragraph: StartParagraph = StartParagraph::Anywhere,
span_columns: SpanColumns = SpanColumns::Single,
/// Split Columns: the space between sub-columns and the inset from the column's edges.
split_inside_gutter: f64 = 6.0,
split_outside_gutter: f64 = 0.0,
rule_above: Rule = Rule::default(),
rule_below: Rule = Rule::default(),
tabs: Vec<TabStop> = Vec::new(),
@ -901,10 +788,7 @@ attr_set! {
start_at: Option<u32> = None,
bullet_char: String = "\u{2022}".into(),
number_style: NumberStyle = NumberStyle::Arabic,
/// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in
/// [`expand_list_text`].
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
/// Text after the number/bullet (`^t` = tab).
list_separator: String = "\t".into(),
balance_ragged: bool = false,
optical_margin: bool = false,
@ -933,62 +817,10 @@ attr_set! {
}
}
impl ParaAttrs {
/// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the
/// rule fields to change; the others keep their values in `current` (the resolved attributes
/// of the paragraph or style being edited).
pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> {
let rule = match key.chars().filter(|c| *c != '_').collect::<String>().to_ascii_lowercase().as_str() {
"ruleabove" => &current.rule_above,
"rulebelow" => &current.rule_below,
_ => return self.set_json(key, value),
};
let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) };
let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?;
let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) };
for (k, v) in patch {
if !fields.contains_key(k) {
return Err(format!("{key}: unknown rule field `{k}`"));
}
fields.insert(k.clone(), v.clone());
}
self.set_json(key, &merged)
}
}
impl ParaProps {
/// The label of a numbered paragraph with number `n`.
pub fn number_label(&self, n: u32) -> String {
expand_list_text(&self.number_expression, &self.number_style.format(n))
}
/// The label of a bulleted paragraph.
pub fn bullet_label(&self) -> String {
let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect();
s.push_str(&expand_list_text(&self.list_separator, ""));
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_partial_rule_keeps_the_other_fields() {
let current = ParaProps {
rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() },
..ParaProps::default()
};
let mut a = ParaAttrs::default();
a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), &current).unwrap();
assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() }));
assert_eq!(a.rule_above, None);
assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), &current).is_err(), "unknown field");
a.set_json_over("ruleBelow", &serde_json::Value::Null, &current).unwrap();
assert_eq!(a.rule_below, None, "null clears the override");
}
#[test]
fn digits_map_to_their_script() {
assert_eq!(Digits::Default.map('7', "Arabic"), '7');
@ -1153,22 +985,5 @@ mod tests {
assert_eq!(NumberStyle::UpperLetters.format(27), "AA");
assert_eq!(NumberStyle::LowerLetters.format(28), "bb");
assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03");
assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007");
assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012");
// Repeating forms stay small for huge numbers.
assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string());
}
#[test]
fn list_labels_expand_metacharacters() {
let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() };
assert_eq!(p.number_label(2), "B.\t");
let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() };
assert_eq!(p.number_label(3), "Tabel 3\t");
assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^");
assert_eq!(expand_list_text("a\nb^p", "1"), "ab");
assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t");
}
}

View File

@ -34,7 +34,6 @@ pub const PRESETS: &[Preset] = &[
Preset { name: "A3", intent: Intent::Print, width: 297.0 * MM, height: 420.0 * MM, units: Unit::Millimeters },
Preset { name: "A4", intent: Intent::Print, width: 210.0 * MM, height: 297.0 * MM, units: Unit::Millimeters },
Preset { name: "A5", intent: Intent::Print, width: 148.0 * MM, height: 210.0 * MM, units: Unit::Millimeters },
Preset { name: "A6", intent: Intent::Print, width: 105.0 * MM, height: 148.0 * MM, units: Unit::Millimeters },
Preset { name: "B5", intent: Intent::Print, width: 176.0 * MM, height: 250.0 * MM, units: Unit::Millimeters },
Preset { name: "Business Card", intent: Intent::Print, width: 252.0, height: 144.0, units: Unit::Picas },
Preset { name: "Postcard", intent: Intent::Print, width: 432.0, height: 288.0, units: Unit::Picas },

View File

@ -87,19 +87,6 @@ pub enum CharacterAlignment {
IcfBottom,
}
/// How the short lines of a warichu run sit inside the run's block.
/// `Auto` centres them. `Justify` spreads a short line out to the block width.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WarichuAlignment {
#[default]
Auto,
Left,
Center,
Right,
Justify,
}
impl Kinsoku {
/// Clean-room Unicode rules for the named CJK sets. Explicit document tables
/// are imported separately and always take precedence over these defaults.

View File

@ -171,18 +171,11 @@ impl Document {
Ok(removed)
}
/// The column direction a new story starts with: right to left in a document whose binding is
/// right to left (Document Setup), else left to right. Stories already made keep theirs.
pub fn new_story_direction(&self) -> crate::TextDirection {
if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight }
}
/// Create a text frame with a new (or given) story at `rect` on spread `r`.
pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> {
let id = ItemId(self.alloc());
let sid = StoryId(self.alloc());
let mut story = Story::with_text(sid, text, para);
story.direction = self.new_story_direction();
story.frames.push(id);
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
@ -200,229 +193,87 @@ impl Document {
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the
/// frame that followed it, if any). If `to` held its own text, that text is appended to the
/// story (as new paragraphs) and its old story is removed; frames that followed `to` come
/// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or
/// `to` becomes a text frame. `from`'s story keeps its id.
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
self.check_thread_links(from, to, false)?;
let ts = self.frame_story(to)?;
let fs = match self.frame_story(from)? {
Some(s) => s,
None => self.give_empty_story(from)?,
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
let Some(ts) = ts else {
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.anchors.extend(other.anchors.iter().cloned());
st.xrefs.extend(other.xrefs.iter().cloned());
st.index_refs.extend(other.index_refs.iter().cloned());
st.objects.extend(other.objects.iter().cloned());
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = other.vertical;
ns.direction = other.direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
st.rev += 1;
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
return Ok(());
};
let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?);
// Frames of the other story from `to` on follow along; frames before it are left as an empty new story.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
let offset;
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
st.append_story(other);
(st.vertical, st.direction) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
if let Some(st) = self.story_mut(fs) {
st.rev += 1;
}
self.remap_thread_story_refs(ts, fs, offset);
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
Ok(())
}
/// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of
/// the first frame of `to`'s story, joins that story: the story with the text keeps its id
/// (and whatever refers to it).
pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> {
let fs = self.frame_story(from)?;
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
let ts = self.frame_story(to)?;
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
self.check_thread_links(from, to, true)?;
if let Some(fs) = fs {
self.stories.remove(&fs);
self.remap_thread_story_refs(fs, ts, 0);
}
let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?;
st.frames.insert(0, from);
st.rev += 1;
let it = self.item_mut(from).ok_or(DocError::NoItem(from))?;
match it.text_frame_mut() {
Some(t) => t.story = ts,
None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }),
}
Ok(())
}
/// Story-external text addresses move with the incoming text when threading merges stories.
/// Linked children follow the surviving whole story, including for an empty removed parent;
/// their copied content stays unchanged until an explicit Update Link.
fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) {
// The child's old snapshot is not a snapshot of the newly merged parent. Force it
// out of date even when the old and surviving revision numbers happen to coincide.
let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1));
for st in self.stories.values_mut() {
if st.link.is_some_and(|(parent, _)| parent == from) {
Arc::make_mut(st).link = Some((to, stale_rev));
}
}
for link in &mut self.hyperlinks {
if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
for placeholder in &mut self.data_merge.placeholders {
if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
if self.settings.primary_story == Some(from) {
self.settings.primary_story = Some(to);
}
}
/// A linked child is replaced wholesale on update. Only empty, unlinked frame
/// extensions can join it without losing a link or newly appended body text.
fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> {
let fs = self.frame_story(from)?;
let ts = self.frame_story(to)?;
let first = fs.and_then(|id| self.story(id));
let second = ts.and_then(|id| self.story(id));
let incompatible = if keep_to {
first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs))
} else {
second.is_some_and(|st| st.link.is_some())
|| first.is_some_and(|st| {
st.link.is_some()
&& second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id))
})
};
if incompatible {
return Err(DocError::Invalid("unlink the linked story before merging its text".into()));
}
Ok(())
}
/// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on
/// document spreads or on the same parent spread, not in one story already, and no frame
/// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow).
pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
for id in [from, to] {
let it = self.item(id).ok_or(DocError::NoItem(id))?;
if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine {
return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame")));
}
if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) {
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
}
}
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
let same_place = match (fl.spread, tl.spread) {
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
(a, b) => a == b,
};
if !same_place {
return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into()));
}
let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story);
if story(from).is_some() && story(from) == story(to) {
return Err(DocError::Invalid("the frames are already in the same thread".into()));
}
// Generated text is rebuilt from its source data. Merging body text into it would
// silently discard that text on the next update (endnotes update after every edit).
let generated = |sid| {
self.endnote_story == Some(sid)
|| self.toc.as_ref().is_some_and(|t| t.story == sid)
|| self.index.as_ref().is_some_and(|i| i.story == sid)
};
let (fs, ts) = (story(from), story(to));
if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) {
return Err(DocError::Invalid("cannot merge body text into a generated story".into()));
}
if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) {
return Err(DocError::Invalid("a generated story can only take a new empty first frame".into()));
}
let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from]))
&& ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to));
self.check_thread_links(from, to, keep_to)?;
if self.prev_frame(to).is_some() {
return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first")));
}
Ok(())
}
/// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for
/// an empty (unassigned) frame.
fn frame_story(&self, id: ItemId) -> Result<Option<StoryId>> {
match &self.item(id).ok_or(DocError::NoItem(id))?.content {
Content::Text(t) => Ok(Some(t.story)),
Content::Unassigned => Ok(None),
_ => Err(DocError::Invalid("can only thread text frames or empty frames".into())),
}
}
/// Make an empty frame a text frame with a new empty story.
fn give_empty_story(&mut self, id: ItemId) -> Result<StoryId> {
let sid = StoryId(self.alloc());
let mut st = Story::new(sid);
st.frames.push(id);
let it = self.item_mut(id).ok_or(DocError::NoItem(id))?;
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.stories.insert(sid, Arc::new(st));
Ok(sid)
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
@ -451,30 +302,6 @@ impl Document {
Ok(())
}
/// Take `frame` out of its thread: the frames before and after it join up and keep the text,
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev += 1;
(st.vertical, st.direction)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
@ -582,11 +409,5 @@ 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;
}
// 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)
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, 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;

View File

@ -21,24 +21,3 @@ id_type!(SpreadId, "sp");
id_type!(PageId, "p");
id_type!(LayerId, "l");
id_type!(AssetId, "a");
/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must
/// not make the next insertion overwrite it. In that case choose the first unused gap.
pub(crate) fn next_local_id(ids: impl Iterator<Item = u64> + Clone) -> u64 {
if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) {
return next;
}
let mut used: Vec<_> = ids.collect();
used.sort_unstable();
let mut next = 1u64;
for id in used {
if id > next {
break;
}
if id == next {
// An allocated Vec cannot contain every u64 id, so this always finds a gap.
next = next.saturating_add(1);
}
}
next
}

View File

@ -281,10 +281,6 @@ pub enum FirstBaseline {
Leading,
XHeight,
Fixed,
/// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo
/// ascender when the typo ascender and descender span one em; else half an em above half the
/// cap height.
EmboxHeight,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -325,18 +321,11 @@ pub struct TextFrameOptions {
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
/// Reference point (0..9) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
pub column_rule_color: String,
/// 0..1.
pub column_rule_tint: f32,
/// Horizontal offset of each rule from the middle of its gutter.
pub column_rule_offset: f64,
/// The rule starts this far below the column top and ends this far above its bottom.
pub column_rule_top_inset: f64,
pub column_rule_bottom_inset: f64,
/// Use a custom baseline grid for this frame.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_grid: Option<(f64, f64)>,
@ -349,12 +338,8 @@ 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 (the start bracket).
/// Where the text starts, as a distance along the path.
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<f64>,
/// Run the other way (the text sits on the other side).
pub flip: bool,
pub align: PathAlign,
@ -386,14 +371,10 @@ impl Default for TextFrameOptions {
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 4,
auto_size_ref: 1,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
column_rule_tint: 1.0,
column_rule_offset: 0.0,
column_rule_top_inset: 0.0,
column_rule_bottom_inset: 0.0,
baseline_grid: None,
path: None,
}
@ -1160,29 +1141,13 @@ impl Item {
b.inflate(w, w)
}
/// The text frame's text area (inner space): inside the part of a visible stroke that lies
/// within the frame, then the inset, on each edge.
/// The frame's text area (inner space) after inset.
pub fn text_area(&self) -> Rect {
let r = self.inner_bounds();
let s = self.stroke_inside();
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s);
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0]))
}
/// How far a visible stroke reaches inside the path: half its weight centred, all of it inside,
/// none outside.
fn stroke_inside(&self) -> f64 {
let w = self.stroke.weight;
if self.stroke.is_none() || !w.is_finite() {
return 0.0;
}
match self.stroke.align {
StrokeAlign::Center => w / 2.0,
StrokeAlign::Inside => w,
StrokeAlign::Outside => 0.0,
}
}
/// Spread-space centre.
pub fn center(&self) -> Point {
self.bounds().center()
@ -1227,32 +1192,6 @@ mod tests {
assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0));
}
#[test]
fn text_area_starts_inside_the_stroke() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } });
let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0);
let stroked = |align: StrokeAlign, swatch: &str| {
let mut it = it.clone();
it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() };
it.text_area()
};
let black = designcraft_color::swatch::BLACK;
// The part of the stroke inside the frame, on every edge, then the inset.
assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0));
assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0));
assert_eq!(stroked(StrokeAlign::Outside, black), unstroked);
// A stroke without a colour doesn't count.
assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked);
// Nor does a weight that isn't a number.
it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() };
assert_eq!(it.text_area(), unstroked);
// A stroke wider than the frame leaves no room for text.
it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() };
let a = it.text_area();
assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}");
}
#[test]
fn group_bounds_union_children() {
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));

View File

@ -12,7 +12,6 @@
pub mod anchored;
pub mod arabic;
pub mod arrow;
pub mod assets;
pub mod attrs;
pub mod build;
pub mod cjk;
@ -37,7 +36,6 @@ 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,
@ -143,7 +141,7 @@ impl Default for DocumentGrid {
}
/// A named numbered list.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
@ -151,6 +149,12 @@ pub struct NumberedList {
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -446,16 +450,6 @@ 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
}

View File

@ -178,7 +178,7 @@ impl Story {
}
pub(crate) fn next_note_id(&self) -> u64 {
crate::ids::next_local_id(self.notes.iter().map(|n| n.id))
self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
@ -235,10 +235,10 @@ impl crate::Document {
before.sort_by_key(|st| key(st));
let mut n = 0u32;
for st in before {
for (p, r) in st.paras.iter().zip(st.para_ranges()) {
for p in &st.paras {
let (pp, _) = self.styles.resolve_para(p);
if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() {
n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1));
if pp.list_type == crate::ListType::Numbers && pp.list_name == name {
n = pp.start_at.map_or(n + 1, |s| s.max(1));
}
}
}

View File

@ -246,8 +246,7 @@ impl Page {
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let n = n.max(1) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
@ -404,18 +403,3 @@ mod tests {
assert_eq!(s.page_at_x(5000.0), Some(1));
}
}
#[cfg(test)]
mod column_allocation_safety_tests {
use super::*;
#[test]
fn column_allocation_caps_page_count_and_preserves_geometry() {
let area = Rect::new(0., 0., 300., 100.);
// Fail safely at a small boundary before exercising hostile counts.
assert_eq!(column_rects(area, 217, 0.).len(), 216);
assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216);
assert_eq!(column_rects(area, 0, 12.), vec![area]);
assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]);
}
}

View File

@ -126,66 +126,6 @@ impl Story {
self.rev += 1;
end
}
/// Append the whole of `other` as new paragraphs after this story's text (threading two
/// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers,
/// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get
/// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay.
pub fn append_story(&mut self, mut other: Story) {
if other.text.is_empty() {
return;
}
if self.text.is_empty() {
let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take());
*self = Story { id, frames, rev: rev.saturating_add(1), link, ..other };
return;
}
if !other.tables.is_empty() {
let mut ids = std::collections::HashMap::new();
let mut next = crate::ids::next_local_id(self.tables.keys().copied());
for (old, t) in std::mem::take(&mut other.tables) {
let new = next;
let mut t = Arc::unwrap_or_clone(t);
t.id = new;
ids.insert(old, new);
self.tables.insert(new, Arc::new(t));
// Continue through free ids without rescanning the table map for its maximum.
// On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id.
next = next.checked_add(1).unwrap_or(1);
while self.tables.contains_key(&next) {
next = next.checked_add(1).unwrap_or(1);
}
}
for p in &mut other.paras {
p.table = p.table.and_then(|id| ids.get(&id).copied());
}
}
self.text.push('\n');
self.text.push_str(&other.text);
// The paragraph end takes the last run's format.
if let Some(last) = self.chars.last_mut() {
last.len += 1;
}
self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0));
self.paras.extend(other.paras);
for n in other.notes {
let id = self.next_note_id();
self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.endnotes {
let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id));
self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.editorial {
let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id));
self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() }));
}
self.anchors.extend(other.anchors);
self.xrefs.extend(other.xrefs);
self.index_refs.extend(other.index_refs);
self.objects.extend(other.objects);
self.rev = self.rev.saturating_add(1);
}
}
#[cfg(test)]
@ -219,52 +159,4 @@ mod tests {
assert_eq!(part.notes.len(), 1);
assert!(a.extract(2..8).notes.is_empty());
}
#[test]
fn append_tables_preserves_sparse_and_wrapping_ids() {
for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] {
let story = |id, ids: &[u64]| {
let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default());
for (p, &tid) in s.paras.iter_mut().zip(ids) {
p.table = Some(tid);
let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0);
t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default());
s.tables.insert(tid, Arc::new(t));
}
s
};
let mut a = story(1, &existing);
let b = story(2, &[1, 2, 3]);
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
a.check().unwrap();
assert_eq!(a.tables.len(), 6);
for (p, tid) in a.paras.iter().take(3).zip(&existing) {
assert_eq!(p.table, Some(*tid));
assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}"));
}
for (p, tid) in a.paras.iter().skip(3).zip(1..=3) {
assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}"));
}
}
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0)));
let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) });
a.endnotes.push(note(u64::MAX));
a.notes.push(note(u64::MAX));
a.rev = u64::MAX;
let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default());
b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0)));
b.endnotes.push(note(1));
b.notes.push(note(1));
a.append_story(b);
assert_eq!(a.text, "Alpha\nBeta");
assert_eq!(a.endnotes.len(), 2);
assert_eq!(a.notes.len(), 2);
}
}

View File

@ -8,9 +8,7 @@
//! the "typing" format).
//!
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
//! break characters ([`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], [`ODD_PAGE_BREAK`],
//! [`EVEN_PAGE_BREAK`]; each ends its paragraph, so it is followed by `\n`, see
//! [`is_break_char`]), `\u{AD}` discretionary hyphen,
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]), [`crate::notes::FOOTNOTE_REF`] (a footnote),
//! [`crate::xref::ANCHOR_MARK`] / [`crate::xref::XREF_MARK`] (text anchor / cross-reference).
@ -36,30 +34,6 @@ pub const PREV_PAGE_NUMBER: char = '\u{E008}';
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
pub const TABLE_ANCHOR: char = '\u{E009}';
pub const FORCED_LINE_BREAK: char = '\u{2028}';
pub const ODD_PAGE_BREAK: char = '\u{E011}';
pub const EVEN_PAGE_BREAK: char = '\u{E012}';
/// A column, frame or page break character. It ends its paragraph: the story stores it as the
/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and
/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from
/// an older document or a raw edit) still moves the rest of that paragraph on.
pub fn is_break_char(c: char) -> bool {
matches!(c, COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK | ODD_PAGE_BREAK | EVEN_PAGE_BREAK)
}
/// `text` with a `\n` after every break character that isn't followed by one. `next` is the
/// character after the text (None at the end of the story).
pub fn end_paragraphs_at_breaks(text: &str, next: Option<char>) -> String {
let mut out = String::with_capacity(text.len() + 1);
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if is_break_char(c) && chars.peek().copied().or(next) != Some('\n') {
out.push('\n');
}
}
out
}
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
pub const NO_CHAR_STYLE: &str = "[None]";
@ -295,23 +269,6 @@ impl Story {
self.rev += 1;
}
/// End the paragraph after every break character that isn't followed by a `\n`.
pub fn end_paragraphs_at_breaks(&mut self) {
let at: Vec<usize> = self
.text
.char_indices()
.filter(|&(i, c)| is_break_char(c) && !self.text[i + c.len_utf8()..].starts_with('\n'))
.map(|(i, c)| i + c.len_utf8())
.collect();
for &pos in at.iter().rev() {
let fmt = self.char_format_at(pos).clone();
self.insert_with_raw(pos, "\n", fmt);
}
if !at.is_empty() {
self.fix_tables();
}
}
/// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format.
pub fn delete(&mut self, range: std::ops::Range<usize>) {
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));

View File

@ -148,236 +148,6 @@ impl Default for Styles {
pub const NO_CELL_STYLE: &str = "[None]";
pub const BASIC_TABLE: &str = "[Basic Table]";
pub const NO_TABLE_STYLE: &str = "[No table style]";
/// Partial edge formatting. Missing attributes inherit; zero weight and the None swatch
/// are explicit values, rather than a request to use the parent/default edge.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CellStrokeAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<crate::item::StrokeType>,
}
impl CellStrokeAttrs {
pub fn is_empty(&self) -> bool {
self.weight.is_none() && self.color.is_none() && self.tint.is_none() && self.kind.is_none()
}
pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) {
if let Some(v) = self.weight {
stroke.weight = v;
}
if let Some(v) = &self.color {
stroke.color = v.clone();
}
if let Some(v) = self.tint {
stroke.tint = v;
}
if let Some(v) = &self.kind {
stroke.kind = v.clone();
}
}
fn merge(&mut self, other: &Self) {
if other.weight.is_some() {
self.weight = other.weight;
}
if other.color.is_some() {
self.color = other.color.clone();
}
if other.tint.is_some() {
self.tint = other.tint;
}
if other.kind.is_some() {
self.kind = other.kind.clone();
}
}
fn from_stroke(stroke: &crate::table::CellStroke) -> Self {
Self { weight: Some(stroke.weight), color: Some(stroke.color.clone()), tint: Some(stroke.tint), kind: Some(stroke.kind.clone()) }
}
}
fn no_edge_attrs(edges: &[CellStrokeAttrs; 4]) -> bool {
edges.iter().all(CellStrokeAttrs::is_empty)
}
fn no_inset_attrs(insets: &[Option<f64>; 4]) -> bool {
insets.iter().all(Option::is_none)
}
/// Partial alternating-fill settings, retained separately from the legacy complete value.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltFillsAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_last: Option<u32>,
}
impl AltFillsAttrs {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
/// Overlay supplied attributes, retaining explicit zero counts and None swatches.
pub fn merge(&mut self, other: &Self) {
if other.first.is_some() {
self.first = other.first;
}
if other.first_color.is_some() {
self.first_color = other.first_color.clone();
}
if other.first_tint.is_some() {
self.first_tint = other.first_tint;
}
if other.next.is_some() {
self.next = other.next;
}
if other.next_color.is_some() {
self.next_color = other.next_color.clone();
}
if other.next_tint.is_some() {
self.next_tint = other.next_tint;
}
if other.skip_first.is_some() {
self.skip_first = other.skip_first;
}
if other.skip_last.is_some() {
self.skip_last = other.skip_last;
}
}
fn from_fills(fills: &crate::table::AltFills) -> Self {
Self {
first: Some(fills.first),
first_color: Some(fills.first_color.clone()),
first_tint: Some(fills.first_tint),
next: Some(fills.next),
next_color: Some(fills.next_color.clone()),
next_tint: Some(fills.next_tint),
skip_first: Some(fills.skip_first),
skip_last: Some(fills.skip_last),
}
}
pub fn apply_to(&self, fills: &mut Option<crate::table::AltFills>) {
if self.is_empty() {
return;
}
let mut value = fills.clone().unwrap_or_default();
if let Some(v) = self.first {
value.first = v;
}
if let Some(v) = &self.first_color {
value.first_color = v.clone();
}
if let Some(v) = self.first_tint {
value.first_tint = v;
}
if let Some(v) = self.next {
value.next = v;
}
if let Some(v) = &self.next_color {
value.next_color = v.clone();
}
if let Some(v) = self.next_tint {
value.next_tint = v;
}
if let Some(v) = self.skip_first {
value.skip_first = v;
}
if let Some(v) = self.skip_last {
value.skip_last = v;
}
*fills = (value.first > 0).then_some(value);
}
}
/// Partial table alternating strokes; missing attributes inherit independently.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltStrokesAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<u32>,
#[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")]
pub first_stroke: CellStrokeAttrs,
#[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")]
pub next_stroke: CellStrokeAttrs,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_last: Option<u32>,
}
impl AltStrokesAttrs {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
pub fn merge(&mut self, other: &Self) {
if other.first.is_some() {
self.first = other.first;
}
if other.next.is_some() {
self.next = other.next;
}
self.first_stroke.merge(&other.first_stroke);
self.next_stroke.merge(&other.next_stroke);
if other.skip_first.is_some() {
self.skip_first = other.skip_first;
}
if other.skip_last.is_some() {
self.skip_last = other.skip_last;
}
}
pub fn apply_to(&self, strokes: &mut Option<crate::table::AltStrokes>) {
if self.is_empty() {
return;
}
let mut value = strokes.clone().unwrap_or_default();
if let Some(v) = self.first {
value.first = v;
}
if let Some(v) = self.next {
value.next = v;
}
self.first_stroke.apply_to(&mut value.first_stroke);
self.next_stroke.apply_to(&mut value.next_stroke);
if let Some(v) = self.skip_first {
value.skip_first = v;
}
if let Some(v) = self.skip_last {
value.skip_last = v;
}
// Zero counts disable rendering, but the remaining settings still belong to
// the document (and are reused if the pattern is enabled later).
*strokes = Some(value);
}
}
/// A cell style: the cell attributes it sets (unset = left as is) and a paragraph style for the
/// cell's text.
@ -386,24 +156,16 @@ impl AltStrokesAttrs {
pub struct CellStyle {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insets: Option<[f64; 4]>,
/// Per-side values override `insets`; top, left, bottom, right.
#[serde(skip_serializing_if = "no_inset_attrs")]
pub inset_overrides: [Option<f64>; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub vj: Option<crate::item::VerticalJustification>,
/// All four edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<crate::table::CellStroke>,
/// Per-edge attributes override the legacy uniform `stroke`.
#[serde(skip_serializing_if = "no_edge_attrs")]
pub strokes: [CellStrokeAttrs; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraph_style: Option<String>,
}
@ -414,16 +176,6 @@ pub struct CellStyle {
pub struct TableStyle {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub footer_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_column_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_column_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
@ -435,18 +187,8 @@ pub struct TableStyle {
pub right_column: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub border: Option<crate::table::CellStroke>,
#[serde(skip_serializing_if = "no_edge_attrs")]
pub borders: [CellStrokeAttrs; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_rows: Option<crate::table::AltFills>,
#[serde(skip_serializing_if = "AltFillsAttrs::is_empty")]
pub row_fills: AltFillsAttrs,
#[serde(skip_serializing_if = "AltFillsAttrs::is_empty")]
pub column_fills: AltFillsAttrs,
#[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")]
pub row_strokes: AltStrokesAttrs,
#[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")]
pub column_strokes: AltStrokesAttrs,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -465,25 +207,11 @@ impl CellStyle {
if let Some(v) = self.insets {
cell.insets = v;
}
for (slot, value) in cell.insets.iter_mut().zip(self.inset_overrides) {
if let Some(value) = value {
*slot = value;
}
}
if let Some(v) = self.vj {
cell.vj = v;
}
if let Some(v) = &self.stroke {
cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()];
cell.border_overrides = [true; 4];
cell.stroke_defined = [true; 4];
}
for (i, edge) in self.strokes.iter().enumerate() {
edge.apply_to(&mut cell.strokes[i]);
if !edge.is_empty() {
cell.border_overrides[i] = true;
cell.stroke_defined[i] = true;
}
}
if let Some(ps) = &self.paragraph_style {
for p in &mut cell.text.paras {
@ -496,33 +224,14 @@ impl CellStyle {
}
impl TableStyle {
/// Write the resolved table options, then region cell styles in precedence order:
/// body, left/right columns, header/footer. Explicit cell formatting is applied later.
/// Write this style into `t`: options, then each cell's region style.
pub fn apply_to(&self, t: &mut crate::table::Table, cells: &[CellStyle]) {
if let Some(b) = &self.border {
t.options.border = b.clone();
t.options.borders = Default::default();
}
for (i, attrs) in self.borders.iter().enumerate() {
if !attrs.is_empty() {
let mut edge = t.options.border_for(i).clone();
attrs.apply_to(&mut edge);
t.options.borders[i] = Some(edge);
}
}
if self.borders.iter().any(|edge| !edge.is_empty()) {
// Keep the legacy uniform accessor representative without changing absent sides.
let borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
}
if self.alt_rows.is_some() {
t.options.alt_rows = self.alt_rows.clone();
}
self.row_fills.apply_to(&mut t.options.alt_rows);
self.column_fills.apply_to(&mut t.options.alt_cols);
self.row_strokes.apply_to(&mut t.options.row_strokes);
self.column_strokes.apply_to(&mut t.options.column_strokes);
if let Some(v) = self.space_before {
t.options.space_before = v;
}
@ -534,148 +243,27 @@ impl TableStyle {
let kind = t.rows[r].kind;
for c in 0..nc {
let visual_column = if t.options.direction == crate::TextDirection::RightToLeft { nc - 1 - c } else { c };
let same_or_region = |same, region: &Option<String>| {
if same == Some(true) { self.body.clone() } else { region.clone() }
let region = match kind {
crate::table::RowKind::Header => self.header.as_ref(),
crate::table::RowKind::Footer => self.footer.as_ref(),
_ if visual_column == 0 && self.left_column.is_some() => self.left_column.as_ref(),
_ if visual_column + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(),
_ => self.body.as_ref(),
};
let left = same_or_region(self.left_column_same_as_body, &self.left_column);
let column = if visual_column == 0 && left.is_some() {
left
} else if visual_column + 1 == nc {
same_or_region(self.right_column_same_as_body, &self.right_column)
} else {
None
};
let row = match kind {
crate::table::RowKind::Header => same_or_region(self.header_same_as_body, &self.header),
crate::table::RowKind::Footer => same_or_region(self.footer_same_as_body, &self.footer),
crate::table::RowKind::Body => None,
};
if let Some(cell) = t.cells.get_mut(r * nc + c) {
for name in [self.body.as_ref(), column.as_ref(), row.as_ref()].into_iter().flatten() {
if name != NO_CELL_STYLE {
resolve_cell_style(cells, name).apply_to(cell);
}
}
if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c))
&& let Some(cs) = cells.iter().find(|s| s.name == *name)
{
cs.apply_to(cell);
}
}
}
t.style = if self.name == BASIC_TABLE { String::new() } else { self.name.clone() };
}
fn merge(&mut self, other: &Self) {
if other.header.is_some() {
self.header = other.header.clone();
}
if other.body.is_some() {
self.body = other.body.clone();
}
if other.footer.is_some() {
self.footer = other.footer.clone();
}
if other.left_column.is_some() {
self.left_column = other.left_column.clone();
}
if other.right_column.is_some() {
self.right_column = other.right_column.clone();
}
if other.header_same_as_body.is_some() {
self.header_same_as_body = other.header_same_as_body;
}
if other.footer_same_as_body.is_some() {
self.footer_same_as_body = other.footer_same_as_body;
}
if other.left_column_same_as_body.is_some() {
self.left_column_same_as_body = other.left_column_same_as_body;
}
if other.right_column_same_as_body.is_some() {
self.right_column_same_as_body = other.right_column_same_as_body;
}
if let Some(border) = &other.border {
self.borders = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(border));
}
for (edge, value) in self.borders.iter_mut().zip(&other.borders) {
edge.merge(value);
}
if let Some(fills) = &other.alt_rows {
self.row_fills = AltFillsAttrs::from_fills(fills);
}
self.row_fills.merge(&other.row_fills);
self.column_fills.merge(&other.column_fills);
self.row_strokes.merge(&other.row_strokes);
self.column_strokes.merge(&other.column_strokes);
if other.space_before.is_some() {
self.space_before = other.space_before;
}
if other.space_after.is_some() {
self.space_after = other.space_after;
}
}
}
/// Resolve a cell style with the same 32-level/cycle limit as paragraph styles.
pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle {
let mut chain = Vec::new();
let mut cur = cells.iter().find(|s| s.name == name);
while let Some(style) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&CellStyle| s.name == style.name) {
break;
}
chain.push(style);
cur = style.based_on.as_deref().and_then(|base| cells.iter().find(|s| s.name == base));
}
let mut out = CellStyle { name: name.into(), ..Default::default() };
for style in chain.into_iter().rev() {
if style.fill.is_some() {
out.fill = style.fill.clone();
}
if style.fill_tint.is_some() {
out.fill_tint = style.fill_tint;
}
if let Some(insets) = style.insets {
out.inset_overrides = insets.map(Some);
}
for (slot, value) in out.inset_overrides.iter_mut().zip(style.inset_overrides) {
if value.is_some() {
*slot = value;
}
}
if style.vj.is_some() {
out.vj = style.vj;
}
if let Some(stroke) = &style.stroke {
out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke));
}
for (slot, attrs) in out.strokes.iter_mut().zip(&style.strokes) {
slot.merge(attrs);
}
if style.paragraph_style.is_some() {
out.paragraph_style = style.paragraph_style.clone();
}
}
out
}
const MAX_DEPTH: usize = 32;
impl Styles {
/// Resolve table style inheritance without recursion; repeated/missing bases stop the chain.
pub fn resolve_table_style(&self, name: &str) -> TableStyle {
let mut chain = Vec::new();
let mut cur = self.table.iter().find(|s| s.name == name);
while let Some(style) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&TableStyle| s.name == style.name) {
break;
}
chain.push(style);
cur = style.based_on.as_deref().and_then(|base| self.table.iter().find(|s| s.name == base));
}
let mut out = TableStyle { name: name.into(), ..Default::default() };
for style in chain.into_iter().rev() {
out.merge(style);
}
out
}
pub fn para(&self, name: &str) -> Option<&ParagraphStyle> {
self.paragraph.iter().find(|s| s.name == name)
}
@ -692,8 +280,8 @@ impl Styles {
self.object.iter().find(|s| s.name == name)
}
/// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
/// The paragraph style chain from root to `name` (cycle-safe).
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
let mut chain = Vec::new();
let mut cur = self.para(name);
while let Some(s) = cur {
@ -748,15 +336,6 @@ impl Styles {
cp
}
/// The attributes character style `name` sets, with those of its Based On chain beneath them.
pub fn char_style_attrs(&self, name: &str) -> CharAttrs {
let mut a = CharAttrs::default();
for s in self.char_chain(name) {
a.merge(&s.chars);
}
a
}
/// Would making `name` based on `parent` create a cycle?
pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool {
name == parent || self.para_chain(parent).iter().any(|s| s.name == name)
@ -823,122 +402,6 @@ mod tests {
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
}
#[test]
fn legacy_cell_and_table_style_json_keep_uniform_values() {
let cell: CellStyle =
serde_json::from_str(r#"{"name":"Legacy","insets":[1,2,3,4],"stroke":{"weight":2,"color":"Brand","tint":0.5,"kind":{"kind":"solid"}}}"#)
.unwrap();
assert!(cell.based_on.is_none());
assert!(no_edge_attrs(&cell.strokes));
assert!(no_inset_attrs(&cell.inset_overrides));
let resolved = resolve_cell_style(std::slice::from_ref(&cell), "Legacy");
let mut target = crate::table::Cell::default();
resolved.apply_to(&mut target);
assert_eq!(target.insets, [1.0, 2.0, 3.0, 4.0]);
assert!(target.strokes.iter().all(|s| s.weight == 2.0 && s.color == "Brand" && s.tint == 0.5));
let json = serde_json::to_value(&cell).unwrap();
assert!(json.get("strokes").is_none());
assert_eq!(serde_json::from_value::<CellStyle>(json).unwrap(), cell);
let table: TableStyle =
serde_json::from_str(r#"{"name":"Legacy","body":"Legacy","border":{"weight":3},"altRows":{"first":2,"firstColor":"Brand"}}"#).unwrap();
let styles = Styles { cell: vec![cell], table: vec![table.clone()], ..Styles::default() };
let mut target = crate::table::Table::new(1, 1, 2, 0, 0, 100.0);
styles.resolve_table_style("Legacy").apply_to(&mut target, &styles.cell);
assert_eq!(target.options.border_for(3).weight, 3.0);
assert_eq!(target.options.alt_rows.as_ref().unwrap().first, 2);
assert_eq!(target.cell(0, 0).unwrap().strokes[0].weight, 2.0);
assert_eq!(serde_json::from_str::<TableStyle>(&serde_json::to_string(&table).unwrap()).unwrap(), table);
}
#[test]
fn table_region_flags_cover_footer_right_column_and_rtl() {
let cells = [
CellStyle { name: "Body".into(), fill: Some("Body".into()), ..Default::default() },
CellStyle { name: "Footer".into(), fill: Some("Footer".into()), ..Default::default() },
CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() },
];
let mut style = TableStyle {
body: Some("Body".into()),
footer: Some("Footer".into()),
right_column: Some("Right".into()),
footer_same_as_body: Some(false),
right_column_same_as_body: Some(false),
..Default::default()
};
for direction in [crate::TextDirection::LeftToRight, crate::TextDirection::RightToLeft] {
let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0);
table.options.direction = direction;
let right = if direction == crate::TextDirection::LeftToRight { 2 } else { 0 };
style.apply_to(&mut table, &cells);
assert_eq!(table.cell(0, right).unwrap().fill, "Right");
assert_eq!(table.cell(1, right).unwrap().fill, "Footer");
}
style.footer_same_as_body = Some(true);
style.right_column_same_as_body = Some(true);
let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0);
style.apply_to(&mut table, &cells);
assert!(table.cells.iter().all(|c| c.fill == "Body"));
}
#[test]
fn single_column_tables_use_a_configured_right_region_when_left_is_absent() {
let cells = [CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }];
let style = TableStyle { right_column: Some("Right".into()), ..Default::default() };
let mut table = crate::table::Table::new(1, 1, 1, 0, 0, 100.0);
style.apply_to(&mut table, &cells);
assert_eq!(table.cell(0, 0).unwrap().fill, "Right");
}
#[test]
fn cell_and_table_style_resolution_stop_at_the_depth_limit() {
let cells: Vec<_> = (0..40)
.map(|i| CellStyle {
name: i.to_string(),
based_on: Some((i + 1).to_string()),
fill: (i == 32).then(|| "Too deep".into()),
fill_tint: (i == 31).then_some(0.5),
..Default::default()
})
.collect();
let resolved = resolve_cell_style(&cells, "0");
assert!(resolved.fill.is_none());
assert_eq!(resolved.fill_tint, Some(0.5));
let styles = Styles {
table: (0..40)
.map(|i| TableStyle {
name: i.to_string(),
based_on: Some((i + 1).to_string()),
space_before: (i == 32).then_some(123.0),
space_after: (i == 31).then_some(15.0),
..Default::default()
})
.collect(),
..Styles::default()
};
let resolved = styles.resolve_table_style("0");
assert!(resolved.space_before.is_none());
assert_eq!(resolved.space_after, Some(15.0));
}
#[test]
fn character_style_attrs_include_its_based_on_chain_only() {
let mut st = Styles::default();
let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle {
name: name.into(),
based_on: based_on.map(Into::into),
chars,
shortcut: String::new(),
};
st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() }));
st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() }));
st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }));
assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() });
assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() });
assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty());
assert!(st.char_style_attrs("Missing").is_empty());
}
#[test]
fn unique_names() {
let names = ["Body", "Body 2"];

View File

@ -79,58 +79,6 @@ impl CellStroke {
}
}
/// Table-level alternating row/column strokes. Retained independently of cell formatting
/// so an explicit cell style or local edge continues to take precedence.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltStrokes {
pub first: u32,
pub next: u32,
pub first_stroke: CellStroke,
pub next_stroke: CellStroke,
pub skip_first: u32,
pub skip_last: u32,
}
impl Default for AltStrokes {
fn default() -> Self {
Self {
first: 0,
next: 0,
first_stroke: CellStroke::default(),
next_stroke: CellStroke { weight: 0.25, ..Default::default() },
skip_first: 0,
skip_last: 0,
}
}
}
impl AltStrokes {
/// A phase-independent stroke for unskipped, visually uniform patterns. Unequal
/// patterns and skip settings are preserved for round trips until their boundary
/// phase is supported; they must not be rendered with a guessed phase.
pub fn uniform_stroke(&self) -> Option<&CellStroke> {
if self.skip_first > 0 || self.skip_last > 0 {
return None;
}
match (self.first > 0, self.next > 0) {
(true, false) => Some(&self.first_stroke),
(false, true) => Some(&self.next_stroke),
(true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => {
Some(&self.first_stroke)
}
_ => None,
}
}
}
fn no_flags(flags: &[bool; 4]) -> bool {
!flags.iter().any(|v| *v)
}
fn no_priorities(priorities: &[i32; 4]) -> bool {
priorities.iter().all(|v| *v == 0)
}
fn one() -> u32 {
1
}
@ -172,12 +120,6 @@ pub struct Cell {
/// top, left, bottom, right order as `strokes`. Older documents keep their table border.
#[serde(default)]
pub border_overrides: [bool; 4],
/// Whether an edge has supplied cell/style attributes, independently of perimeter priority.
#[serde(default, skip_serializing_if = "no_flags")]
pub stroke_defined: [bool; 4],
/// IDML edge-conflict priorities, in top, left, bottom, right order.
#[serde(default, skip_serializing_if = "no_priorities")]
pub stroke_priorities: [i32; 4],
/// Applied cell style ("" = [None]).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String,
@ -200,8 +142,6 @@ impl Default for Cell {
rotation: 0.0,
strokes: Default::default(),
border_overrides: [false; 4],
stroke_priorities: [0; 4],
stroke_defined: [false; 4],
style: String::new(),
graphic: None,
}
@ -260,12 +200,12 @@ impl AltFills {
/// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it.
pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> {
let (sf, sl) = (self.skip_first as usize, self.skip_last as usize);
if i < sf || i >= n.saturating_sub(sl) {
if i < sf || i + sl >= n {
return None;
}
let period = (u64::from(self.first) + u64::from(self.next)).max(1);
let k = (i - sf) as u64 % period;
let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
let period = (self.first + self.next).max(1) as usize;
let k = (i - sf) % period;
let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
(c != designcraft_color::swatch::NONE).then_some((c.as_str(), t))
}
}
@ -277,9 +217,6 @@ pub struct TableOptions {
pub direction: crate::TextDirection,
/// Outer border (overrides the outer cell edges).
pub border: CellStroke,
/// Per-side borders; absent sides use the legacy uniform `border`.
#[serde(skip_serializing_if = "no_borders")]
pub borders: [Option<CellStroke>; 4],
pub space_before: f64,
pub space_after: f64,
/// Header rows repeat at the top of every text column / frame the table continues into.
@ -289,20 +226,6 @@ pub struct TableOptions {
pub alt_rows: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_cols: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_strokes: Option<AltStrokes>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_strokes: Option<AltStrokes>,
}
fn no_borders(borders: &[Option<CellStroke>; 4]) -> bool {
borders.iter().all(Option::is_none)
}
impl TableOptions {
pub fn border_for(&self, side: usize) -> &CellStroke {
self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border)
}
}
impl Default for TableOptions {
@ -310,15 +233,12 @@ impl Default for TableOptions {
TableOptions {
direction: crate::TextDirection::LeftToRight,
border: CellStroke::default(),
borders: Default::default(),
space_before: 4.0,
space_after: -4.0,
repeat_header: true,
repeat_footer: true,
alt_rows: None,
alt_cols: None,
row_strokes: None,
column_strokes: None,
}
}
}
@ -695,79 +615,6 @@ impl Table {
self.set_regions(&regions);
}
/// 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(&regions);
} 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.
@ -792,11 +639,10 @@ impl Table {
return;
}
if horizontal {
let row = self.rows[or].clone();
let h = self.rows[or].height;
self.insert_rows(or + 1, 1);
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 };
self.rows[or].height = h / 2.0;
self.rows[or + 1].height = h / 2.0;
// 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();
@ -1251,16 +1097,6 @@ mod tests {
assert!(b.fill_for(3, 9).is_none());
}
#[test]
fn alternating_fill_counts_cannot_overflow() {
let fills = AltFills { first: u32::MAX, next: 1, ..Default::default() };
assert!(fills.fill_for(0, 2).is_some());
assert!(fills.fill_for(1, 2).is_some());
let skipped = AltFills { skip_last: u32::MAX, ..fills };
assert!(skipped.fill_for(1, 2).is_none());
assert!(skipped.fill_for(usize::MAX, usize::MAX).is_none());
}
#[test]
fn serde_roundtrip() {
let mut s = story("x");
@ -1282,22 +1118,8 @@ mod tests {
old.as_object_mut().unwrap().remove("borderOverrides");
let back: Cell = serde_json::from_value(old).unwrap();
assert_eq!(back.border_overrides, [false; 4]);
assert_eq!(back.stroke_defined, [false; 4]);
assert_eq!(back.stroke_priorities, [0; 4]);
let legacy_options: TableOptions = serde_json::from_str(r#"{"border":{"weight":3}}"#).unwrap();
assert!(legacy_options.borders.iter().all(Option::is_none));
assert!(legacy_options.row_strokes.is_none());
assert!(legacy_options.column_strokes.is_none());
for side in 0..4 {
assert_eq!(legacy_options.border_for(side).weight, 3.0);
}
let explicit = Cell {
border_overrides: [true, false, true, false],
stroke_defined: [true, true, false, false],
stroke_priorities: [3, 0, 7, 0],
..cell
};
let explicit = Cell { border_overrides: [true, false, true, false], ..cell };
let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap();
assert_eq!(back, explicit);
assert_eq!(back.blank_like().border_overrides, explicit.border_overrides);
@ -1329,89 +1151,4 @@ 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::<std::collections::BTreeSet<_>>().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::<std::collections::BTreeSet<_>>().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);
}
}

View File

@ -42,126 +42,6 @@ fn thread_to_empty_frame_converts_it() {
assert!(d.thread(a, a).is_err());
}
#[test]
fn threading_merges_tables_notes_and_endnotes() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap();
for (sid, note) in [(sa, "Note A"), (sb, "Note B")] {
let st = d.story_mut(sid).unwrap();
st.insert_note(3, note, ParaFormat::default());
// Both stories hold a table with id 1.
let n = st.len();
st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0));
}
let st = d.story_mut(sb).unwrap();
st.insert_endnote(2, "End B", ParaFormat::default());
st.insert(1, &crate::NOTE_MARK.to_string());
d.thread(a, b).unwrap();
d.check().unwrap();
let st = d.story(sa).unwrap();
assert!(d.story(sb).is_none());
assert_eq!(st.tables.len(), 2);
assert_eq!(st.notes.len(), 2);
assert_eq!(st.notes[1].text.text, "Note B");
assert_eq!(st.endnotes.len(), 1);
assert_eq!(st.endnotes[0].text.text, "End B");
assert_eq!(st.editorial.len(), 1);
}
#[test]
fn empty_frame_threaded_before_a_story_joins_it_at_the_front() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread_keeping_story(a, b).unwrap();
d.check().unwrap();
assert!(d.story(sa).is_none());
let st = d.story(sb).unwrap();
assert_eq!(st.frames, vec![a, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() {
let mut d = doc();
let lid = d.default_layer();
let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, a2).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(a2, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![a, a2, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn threading_from_an_empty_frame_makes_it_a_text_frame() {
let mut d = doc();
let lid = d.default_layer();
let id = ItemId(d.alloc());
let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0)));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(id, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![id, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
d.thread(b, c).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert_eq!(d.story(sa).unwrap().frames, vec![a, c]);
assert_eq!(d.story(sa).unwrap().text, "words");
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_ne!(own, sa);
assert!(d.story(own).unwrap().text.is_empty());
// A frame alone in its story has no thread to leave.
assert!(d.remove_from_thread(b).is_err());
}
#[test]
fn a_frame_removed_from_a_vertical_thread_stays_vertical() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().vertical = true;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert!(d.frame_vertical(d.item(a).unwrap()));
assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction");
}
#[test]
fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft);
}
#[test]
fn frames_keep_their_story_direction_through_threading() {
let mut d = doc();

View File

@ -347,18 +347,13 @@ impl Document {
let (sid, pos) = self.find_anchor(id)?;
let st = self.story(sid)?;
let pi = st.para_at(pos);
let ranges = st.para_ranges();
let r = ranges[pi].clone();
let r = st.para_ranges()[pi].clone();
let (pp, _) = self.styles.resolve_para(&st.paras[pi]);
let para_num = match pp.list_type {
crate::ListType::Numbers if !r.is_empty() => {
// Count the run of numbered paragraphs before this one (empty ones have no number).
let n = (0..=pi)
.rev()
.take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers)
.filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty()))
.count();
pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string()
crate::ListType::Numbers => {
// Count the run of numbered paragraphs before this one.
let n = (0..=pi).rev().take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers).count();
format!("{}.", pp.number_style.format(n as u32))
}
_ => String::new(),
};

View File

@ -9,7 +9,6 @@ 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 }
@ -29,11 +28,6 @@ thiserror = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
quick-xml = { workspace = true }
caseless = { workspace = true }
unicode-segmentation = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
@ -42,6 +36,3 @@ hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
hayro-cmap = "0.1"
kurbo = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"

View File

@ -1,359 +0,0 @@
//! 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<tempfile::NamedTempFile> {
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<PathBuf> {
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<String> {
let mut v: Vec<String> = 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);
}
}

View File

@ -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()))?;
crate::atomic::write_file(&b.path, &bytes)
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
}
#[cfg(target_arch = "wasm32")]
@ -47,176 +47,20 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
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).map_err(|e| EngineError::Other(format!("{path}: {e}")))
super::file::from_bytes(&bytes)
}
#[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<Self> {
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(())
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"))]
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<Value> {
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<Value> {
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<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(target_arch = "wasm32")]
fn sync_styles(_: &Session) -> Result<Value> {
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(
@ -264,16 +108,15 @@ pub fn specs() -> Vec<CommandSpec> {
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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
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)?;
s.book = Some(b);
save(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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
if i >= b.documents.len() {
return Err(bad("book.remove", format!("no document {i}")));
}
@ -281,19 +124,17 @@ pub fn specs() -> Vec<CommandSpec> {
if b.style_source >= b.documents.len() {
b.style_source = 0;
}
save(&b)?;
s.book = Some(b);
save(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 mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let b = s.book.as_mut().ok_or_else(no_book)?;
if i >= b.documents.len() {
return Err(bad("book.styleSource", format!("no document {i}")));
}
b.style_source = i;
save(&b)?;
s.book = Some(b);
save(b)?;
ok()
}),
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
@ -308,10 +149,48 @@ pub fn specs() -> Vec<CommandSpec> {
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, _| {
paginate(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}))
}),
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, _| {
sync_styles(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}))
}),
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)?;
@ -331,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
@ -356,7 +235,6 @@ 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();

View File

@ -1,52 +0,0 @@
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"));
}

View File

@ -75,11 +75,6 @@ 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) {
@ -88,15 +83,6 @@ 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);
@ -108,8 +94,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
}
}
Content::Graphic(g) => {
if let Some(a) = dst.adopt_asset(src, g.asset) {
g.asset = a;
if let Some(a) = src.assets.get(&g.asset) {
dst.assets.entry(g.asset).or_insert_with(|| a.clone());
}
}
Content::Group { items } => {
@ -369,18 +355,21 @@ fn place_image(
};
let (nw, nh) = (f64::from(pw), f64::from(ph));
let mode = fitting_of(&options.fitting);
let fresh = AssetId(doc.alloc());
let aid = 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());
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,
});
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 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;

View File

@ -367,16 +367,10 @@ fn sniff(name: &str, text: &str) -> Delimiter {
if ext == "csv" {
return Delimiter::Comma;
}
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) {
let header = first_physical_line(text);
if header.contains('\t') {
Delimiter::Tab
} else if eligible(&semicolon) && !eligible(&comma) {
} else if header.contains(';') && !header.contains(',') {
Delimiter::Semicolon
} else {
Delimiter::Comma
@ -387,66 +381,8 @@ pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
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 }
fn first_physical_line(text: &str) -> &str {
text.split('\n').next().unwrap_or("").trim_end_matches('\r')
}
/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr.
@ -516,7 +452,6 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
let mut row: Vec<String> = 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;
@ -566,25 +501,21 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
};
while let Some(c) = chars.next() {
if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) {
if c == '\n' {
line = line.saturating_add(1);
}
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
if !discarding {
field.push('"');
}
field.push('"');
chars.next();
}
'"' => quoted = false,
_ if !discarding => field.push(c),
_ => {}
_ => field.push(c),
}
continue;
}
if c == delim {
field_start = true;
if discarding {
field.clear();
continue;
@ -600,25 +531,14 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
continue;
}
match c {
'"' if field_start => {
quoted = true;
field_start = false;
}
'\r' | '\n' => {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
line = line.saturating_add(1);
}
'"' if field.is_empty() && !discarding => quoted = true,
'\r' => {}
'\n' => {
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 {

View File

@ -85,282 +85,6 @@ 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::<Vec<_>>(), 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");
@ -990,8 +714,7 @@ fn relative_path_and_reopen() {
std::fs::create_dir_all(&b).unwrap();
let csv = b.join("data.csv");
write(&csv, "Name\nAda\n");
let relative = Path::new("..").join("b").join("data.csv").to_string_lossy().into_owned();
assert_eq!(relative_between(&a, &csv), relative);
assert_eq!(relative_between(&a, &csv), "../b/data.csv");
assert_eq!(relative_between(&b, &csv), "data.csv");
let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
@ -1020,7 +743,7 @@ fn relative_path_and_reopen() {
let mut s2 = Session::new();
exec(&mut s2, "file.open", json!({"path": saved}));
let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0];
assert_eq!(src.relative_path.as_deref(), Some(relative.as_str()));
assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv"));
assert_eq!(src.rows[0][0], "Ada");
}
@ -1085,66 +808,3 @@ fn oversized_data_file_is_refused_before_reading() {
assert!(msg.contains("larger than"), "{msg}");
assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048);
}
/// #198: with facing pages on, one record per page must not stack the copies at identical
/// coordinates on one spread (leaving a later page empty).
#[test]
fn facing_pages_merge_puts_each_record_on_its_own_page() {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792}));
select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n");
// A block taller than the page's usable height, so only one record fits per page.
text_frame(&mut s, [72.0, 72.0, 540.0, 700.0], "<<Title>> - <<Clue>>", None);
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 3);
let doc = &s.documents()[s.active_index().unwrap()].doc;
assert_eq!(doc.page_count(), 3, "one page per record");
// Which absolute page each merged frame sits on.
let mut pages_seen = std::collections::BTreeSet::new();
let mut origins = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
if !matches!(it.content, Content::Text(_)) {
continue;
}
let abs = doc.page_of_item(it.id);
let b = it.bounds();
origins.push((abs, b.x0.round() as i32, b.y0.round() as i32));
if let Some(a) = abs {
pages_seen.insert(a);
}
}
}
assert_eq!(origins.len(), 3, "one text frame per record");
assert_eq!(pages_seen, [0usize, 1, 2].into_iter().collect(), "each record on its own page, none stacked and none left empty: {origins:?}");
}
/// #198's actual repro shape: a short block that tiles several records down one page. With
/// facing pages on they must still land at distinct positions, not on top of each other.
#[test]
fn facing_pages_merge_tiles_short_blocks_without_stacking() {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792}));
select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n");
text_frame(&mut s, [72.0, 72.0, 540.0, 200.0], "<<Title>> - <<Clue>>", None);
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 3);
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut origins = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
if matches!(it.content, Content::Text(_)) {
let abs = doc.page_of_item(it.id);
let b = it.bounds();
origins.push((abs, b.x0.round() as i32, b.y0.round() as i32));
}
}
}
origins.sort();
assert_eq!(origins.len(), 3, "one text frame per record");
let unique: std::collections::BTreeSet<_> = origins.iter().collect();
assert_eq!(unique.len(), 3, "no two records may share a page and coordinates: {origins:?}");
}

View File

@ -20,6 +20,13 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
}
fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_clip(s)
}
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
return Ok(());
@ -81,73 +88,7 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
});
}
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
}
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
/// variables, since a stray one would be read as a marker without its payload.
fn outside_text(text: &str) -> Result<String> {
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
}
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
/// `slice` (the text copied here), the frame's story is that formatted copy.
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
use designcraft_geom::{Point, Rect, shapes};
const LIMIT: f64 = 1.0e5;
let sr = super::spread_param(p, "spread")?;
let at = super::point_param(p, "at")
.filter(|a| a.x.is_finite() && a.y.is_finite())
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
let lid = s.doc()?.active_layer;
let cache = s.cache.clone();
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
s.edit(|d, sel| {
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?;
let margins = page.margin_rect();
let column = page.column_rects().first().copied().unwrap_or(margins);
let w = column.width().max(12.0);
let max_h = margins.height().max(12.0);
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
let para = match slice.and_then(|sl| sl.paras.first()) {
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
};
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
if let Some(sl) = slice {
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
}
// Shrink to the composed text unless it already fills the frame.
let composed = cache.get(d, sid, None);
if !composed.is_overset()
&& let Some(ft) = composed.frame(id)
{
let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]);
let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h);
if h.is_finite()
&& h < max_h
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(h));
// Keep the full height if the last line no longer fits.
if cache.get(d, sid, None).is_overset()
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(max_h));
}
}
}
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0, "story": sid.0}))
})
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
}
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
@ -169,8 +110,6 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
// Pending typing overrides belong to the current edit state, not a restored snapshot.
s.typing_format = None;
Ok(json!({"undone": e.label}))
}),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
@ -180,7 +119,6 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
s.typing_format = None;
Ok(json!({"redone": e.label}))
}),
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
@ -289,18 +227,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}",
// The system clipboard may hold text from another app, so Paste is always available.
has_doc,
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}",
has_clip_or_text,
|s, p| {
let text = super::str_param(p, "text").filter(|t| !t.is_empty());
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
return paste_text(s, super::str_param(p, "text").map(str::to_string), true);
}
let Some(clip) = s.clipboard.clone() else {
return Err(super::bad("edit.paste", "clipboard is empty"));
@ -385,87 +316,6 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
}
#[test]
fn paste_text_with_no_insertion_point_makes_a_text_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
// Text from another app, nothing copied here: a new, selected text frame.
let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let d = s.doc().unwrap();
assert_eq!(d.selection.items, vec![id]);
assert!(d.selection.text.is_none(), "selected as an object");
let it = d.doc.item(id).unwrap();
assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line");
let page = &d.doc.spreads[0].pages[0];
let (b, m) = (it.bounds(), page.margin_rect());
assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}");
assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}");
assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}");
assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(items(&s), 0, "one undo removes it");
// Centred on `at`; hostile numbers are clamped.
let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap();
let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds();
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}");
assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err());
s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap();
// Copied objects paste when no text is given.
s.execute("edit.deselectAll", &json!({})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let n = items(&s);
let r = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
assert_eq!(items(&s), n + 1);
assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// Private-use characters from another app would read as footnote, table or variable markers.
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
s.doc().unwrap().doc.check().unwrap();
let b = frame(&mut s, "Target");
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, "Bold words\nSecond");
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
let d = s.doc().unwrap();
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.text, "Bold words\nSecond");
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.char_format_at(7).over.font_style, None);
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
d.doc.check().unwrap();
}
#[test]
fn formatted_copy_paste_and_plain_paste() {
let mut s = Session::new();

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
if ts.cell.is_some() {
return Err(bad("note.new", "notes go in story text"));
}
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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<CommandSpec> {
}),
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(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
let (text, author) = (str_param(p, "text").map(str::to_string), 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}")))?;

View File

@ -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};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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<Value> {
if st.doc.endnote_story == Some(ts.story) {
return Err(bad(ID, "the endnote frame lists the endnotes"));
}
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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.

View File

@ -1,4 +1,4 @@
//! File › Export PDF (Print).
//! File › Export: Adobe PDF (Print).
use designcraft_pdf::{Marks, PdfOptions, Standard};
use serde_json::{Value, json};
@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &r.bytes)?;
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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})),
@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
crate::atomic::write_file(path, bytes)
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {

View File

@ -18,18 +18,16 @@ pub fn specs() -> Vec<CommandSpec> {
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 (and, for .idml, package parts missing)}",
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
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 mut copy = (*s.doc()?.doc).clone();
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
let bytes = to_bytes(&copy);
let bytes = to_bytes(&s.doc()?.doc);
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(&path, &bytes)?;
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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),
@ -39,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
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_tracked(s, &dir)?}))
Ok(json!({"saved": crate::recovery::save(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)?;
@ -126,7 +124,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
nd.height = f64_or(p, "height", nd.height);
nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999);
nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns);
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(nd.columns);
nd.gutter = f64_or(p, "gutter", nd.gutter);
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false);
match p.get("margins") {
@ -166,7 +164,6 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut d = from_bytes(&bytes)?;
super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
let (fonts, faces, warnings) = load_document_fonts(&mut d, path);
let mut st = DocState::new(d, Some(path.to_string()));
@ -188,7 +185,7 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
#[cfg(not(target_arch = "wasm32"))]
{
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (None, 0, Vec::new()) };
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
d.font_scope = r.scope;
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
@ -201,23 +198,6 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
}
}
/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it
/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins.
#[cfg(not(target_arch = "wasm32"))]
fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let exact = dir.join(DOCUMENT_FONTS_FOLDER);
if exact.is_dir() {
return exact;
}
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir())
.map_or(exact, |e| e.path())
}
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
// IDML packages are recognised by their stored `mimetype` first entry.
@ -239,26 +219,24 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
.map(str::to_string)
.or_else(|| st.path.clone())
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
// 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"))]
crate::atomic::write_file(&path, &bytes)?;
if paths_changed {
st.doc = saved;
st.revision = st.revision.saturating_add(1);
{
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);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
st.path = Some(path.clone());
st.saved_revision = st.revision;
st.saved_doc = st.doc.clone();
#[cfg(not(target_arch = "wasm32"))]
{
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());
if let Some(dir) = &s.recovery_dir {
let uid = s.doc()?.uid;
crate::recovery::discard(dir, uid);
}
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@ -301,13 +279,10 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)"));
let one_based = usize::try_from(n).map_err(|_| invalid_page())?;
if one_based == 0 || one_based > count {
return Err(invalid_page());
if n == 0 || n as usize > count {
return Err(bad("file.place", format!("the PDF has {count} page(s)")));
}
let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?;
u32::try_from(zero_based).map_err(|_| invalid_page())?
n as u32 - 1
}
_ => 0,
};
@ -317,9 +292,8 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
.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. 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));
// 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));
// 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) => {
@ -335,15 +309,15 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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, "file.place")?;
let spread = super::spread_param(p, "spread");
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 fresh = AssetId(d.alloc());
let aid = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) });
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, 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,
@ -355,14 +329,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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 the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
// Fill frame proportionally.
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: Default::default(),
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
crop: [0.0; 4],
});
@ -445,20 +419,23 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
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 fresh = AssetId(d.alloc());
let aid = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
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)
})?;
s.loaded = Some((aid, natural));
s.loaded = Some((aid, (pw as f64, ph as f64)));
s.set_tool("placeGun");
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
}
fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
let sr = super::spread_param(p, "place.drop")?;
let sr = super::spread_param(p, "spread");
let lid = s.doc()?.active_layer;
let frame = super::id_param(p, "frame");
let rect = super::rect_param(p, "rect");
@ -473,7 +450,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
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: Default::default(),
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
crop: [0.0; 4],
});
@ -522,7 +499,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
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()})),
@ -550,7 +527,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, ID)?;
let to = super::spread_param(p, "spread");
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),
@ -586,7 +563,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("snippet.place", "the snippet is empty"));
}
let sr = super::spread_param(p, "snippet.place")?;
let sr = super::spread_param(p, "spread");
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),
@ -647,14 +624,15 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
// 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 recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
let st = s.doc_mut()?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
fresh.revision = s.doc()?.revision + 1;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
*s.doc_mut()? = fresh;
fresh.revision = st.revision + 1;
*st = fresh;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
Ok(json!({"path": path}))
}
#[cfg(target_arch = "wasm32")]
@ -690,24 +668,6 @@ 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)]
@ -738,37 +698,6 @@ 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};
@ -866,24 +795,6 @@ mod document_fonts_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_document_fonts_folder_is_found_whatever_its_case() {
const FAMILY: &str = "DocFont Case Test";
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fonts = dir.join("Document fonts");
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
let path = dir.join("Brochure.idml");
write_document(&path, FAMILY);
let mut s = Session::new();
let r = open(&mut s, &path);
assert_eq!(r["documentFonts"], 1, "{r}");
assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_document_without_the_folder_opens_as_before() {
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id()));
@ -989,24 +900,4 @@ mod document_fonts_tests {
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bold_and_italic_link_the_document_fonts_styles() {
const FAMILY: &str = "DocFont Linked";
let dir = temp_dir("linked");
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
let path = dir.join("Linked.designcraft");
write_document(&path, FAMILY);
let mut s = Session::new();
open(&mut s, &path);
let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
// The family is the document's alone: its Regular is the style without bold or italic.
let r = s.execute("type.bold", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let r = s.execute("type.italic", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -80,8 +80,6 @@ fn metachars(s: &str, grep: bool) -> String {
("~M", "\u{E002}"),
("~R", "\u{E003}"),
("~P", "\u{E004}"),
("~L", "\u{E011}"),
("~E", "\u{E012}"),
("~=", "\u{2014}"),
("~_", "\u{2014}"),
("~-", "\u{AD}"),
@ -181,7 +179,6 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
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;

View File

@ -190,39 +190,4 @@ mod tests {
assert_eq!(r["changed"], 1);
assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str()));
}
/// InDesign names a family installed in several formats `Family (OTF)`; the plain family
/// stands for it, and a family that isn't available under either name is still missing.
#[test]
fn a_family_with_a_format_suffix_uses_the_plain_family() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).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": 15})).unwrap();
s.execute("type.char", &json!({"fontFamily": "Source Serif 4 (OTF)", "fontStyle": "Bold"})).unwrap();
let l = s.execute("font.list", &json!({})).unwrap();
let entry = l.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4 (OTF)").unwrap();
assert_eq!((&entry["missing"], &entry["styleMissing"], &entry["source"]), (&json!(false), &json!(false), &json!("bundled")), "{l}");
let st = s.doc().unwrap();
let cs = s.cache.get(&st.doc, sid, None);
let faces: Vec<_> =
cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0).map(|g| g.face.get()).collect();
assert!(!faces.is_empty() && faces.iter().all(|f| f.family == "Source Serif 4" && f.style == "Bold"));
let kinds = |s: &mut Session| {
s.execute("preflight.run", &json!({})).unwrap()["issues"]
.as_array()
.unwrap()
.iter()
.map(|i| i["kind"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
assert!(!kinds(&mut s).iter().any(|k| k == "missingFont"));
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.char", &json!({"fontFamily": "Nope (OTF)"})).unwrap();
let l = s.execute("font.list", &json!({})).unwrap();
assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}");
assert!(kinds(&mut s).iter().any(|k| k == "missingFont"));
}
}

View File

@ -7,13 +7,9 @@ use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc, spread_param, str_param};
use crate::{Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -28,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
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 = super::spread_param(p, "guide.deleteAll")?;
let r = spread_param(p, "spread");
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
@ -41,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
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(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let sp = d.spread(spread_param(p, "spread")).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() {
@ -55,31 +51,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Guides…",
["Layout"],
None,
"{rows?: 0..1000, columns?: 0..1000, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)} — at most 10000 guides per command",
"{rows?: 0, columns?: 0, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
has_doc,
create_guides
),
]
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
}
fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>> {
p.get(key).map(|value| checked_usize(value, key, command)).transpose()
}
fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
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")))?;
if !number.is_finite() {
return Err(bad(command, format!("`{key}` must be a finite number")));
}
Ok(Some(number))
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@ -95,17 +73,17 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let o = orientation(p)?;
let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?;
let r = super::spread_param(p, "guide.add")?;
let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.add", "missing position"))?;
let r = spread_param(p, "spread");
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")? {
Some(pi) => pi,
let pi = match p.get("page").and_then(Value::as_u64) {
Some(pi) => pi as usize,
None if spread_guide => 0,
None => {
let x = match o {
Orientation::Vertical => pos,
Orientation::Horizontal => finite_number(p, "at", "guide.add")?.unwrap_or(0.0),
Orientation::Horizontal => p.get("at").and_then(Value::as_f64).unwrap_or(0.0),
};
page_at(d, r, x)
}
@ -120,9 +98,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
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((super::spread_param(p, cmd)?, pi, i))
let pi = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))? as usize;
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
Ok((spread_param(p, "spread"), pi, i))
}
/// Commands must use the same guide/layer protection as pointer editing.
@ -136,7 +114,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@ -162,89 +140,36 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
if count > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", format!("`{key}` must not exceed {MAX_GUIDE_DIVISIONS}")));
}
Ok(count)
}
fn guide_gutter(p: &Value, key: &str) -> Result<f64> {
let Some(value) = p.get(key) else { return Ok(12.0) };
let gutter = value.as_f64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be a finite number")))?;
if !gutter.is_finite() || !(0.0..=MAX_GUIDE_GUTTER).contains(&gutter) {
return Err(bad("layout.createGuides", format!("`{key}` must be between 0 and {MAX_GUIDE_GUTTER}")));
}
Ok(gutter)
}
fn guide_count(divisions: u64, gutter: f64) -> Result<usize> {
let edges = divisions.saturating_sub(1);
let count = if gutter > 0.0 { edges.checked_mul(2) } else { Some(edges) }
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
usize::try_from(count).map_err(|_| bad("layout.createGuides", "the requested guide count is too large for this platform"))
}
/// Evenly spaced rows/columns with gutters inside `area`: the guide positions.
fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result<Vec<f64>> {
pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec<f64> {
if n < 2 {
return Ok(vec![]);
}
if !a.is_finite() || !b.is_finite() || !gutter.is_finite() || n > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", "guide geometry must be finite and within the supported range"));
return vec![];
}
let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64;
if !cell.is_finite() {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
let mut v = Vec::with_capacity(guide_count(n, gutter)?);
let mut v = Vec::new();
for k in 1..n {
let edge = a + k as f64 * cell + (k - 1) as f64 * gutter;
if !edge.is_finite() || (gutter > 0.0 && !(edge + gutter).is_finite()) {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
v.push(edge);
if gutter > 0.0 {
v.push(edge + gutter);
}
}
Ok(v)
v
}
fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
let rows = guide_divisions(p, "rows")?;
let cols = guide_divisions(p, "columns")?;
let rg = guide_gutter(p, "rowGutter")?;
let cg = guide_gutter(p, "columnGutter")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(0) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(0) as u32;
let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0);
let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0);
let to_page = str_param(p, "fitTo") == Some("page");
let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false);
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"))?;
match only {
Some(page) => {
if sp.pages.get(page).is_none() {
return Err(bad("layout.createGuides", "no such page"));
}
1
}
None => sp.pages.len(),
}
};
let per_page = guide_count(rows, rg)?
.checked_add(guide_count(cols, cg)?)
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
per_page
.checked_mul(page_count)
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let r = spread_param(p, "spread");
let only = p.get("page").and_then(Value::as_u64).map(|v| v as usize);
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
let mut n = 0;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
if only.is_some_and(|o| o != pi) {
continue;
@ -253,7 +178,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
pg.guides.clear();
}
let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() };
for y in grid_positions(area.y0, area.y1, rows, rg)? {
for y in grid_positions(area.y0, area.y1, rows, rg) {
pg.guides.push(Guide {
orientation: Orientation::Horizontal,
position: y,
@ -264,7 +189,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
});
n += 1;
}
for x in grid_positions(area.x0, area.x1, cols, cg)? {
for x in grid_positions(area.x0, area.x1, cols, cg) {
pg.guides.push(Guide {
orientation: Orientation::Vertical,
position: x,
@ -313,35 +238,6 @@ mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5);
}
#[test]
fn create_guides_validates_generation_limits() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS + 1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": -1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": "many"})).is_err());
let r = s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS, "columnGutter": 0})).unwrap();
assert_eq!(r["guides"], MAX_GUIDE_DIVISIONS - 1);
}
#[test]
fn mutating_guide_commands_reject_invalid_indices() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": 100, "page": -1})).is_err());
assert!(s.execute("guide.move", &json!({"page": 0.5, "index": 0, "position": 200})).is_err());
assert!(s.execute("guide.delete", &json!({"page": 0, "index": -1})).is_err());
assert!(s.execute("guide.deleteAll", &json!({"spread": "0"})).is_err());
assert!(s.execute("layout.createGuides", &json!({"spread": -1, "columns": 2})).is_err());
assert!(s.execute("layout.createGuides", &json!({"page": u64::MAX, "columns": 2})).is_err());
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 1);
s.execute("guide.add", &json!({"spread": {"kind": "doc", "index": 0}, "page": 0, "orientation": "horizontal", "position": 150})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
}
}
#[cfg(test)]

View File

@ -41,15 +41,6 @@ fn item_json(it: &Item) -> Value {
Content::Text(t) => {
v["story"] = json!(t.story.0);
v["columns"] = json!(t.options.columns);
v["columnRule"] = json!(t.options.column_rule);
if t.options.column_rule {
v["columnRuleWeight"] = json!(t.options.column_rule_weight);
v["columnRuleColor"] = json!(t.options.column_rule_color);
v["columnRuleTint"] = json!(t.options.column_rule_tint);
v["columnRuleOffset"] = json!(t.options.column_rule_offset);
v["columnRuleTopInset"] = json!(t.options.column_rule_top_inset);
v["columnRuleBottomInset"] = json!(t.options.column_rule_bottom_inset);
}
}
Content::Graphic(g) => v["asset"] = json!(g.asset.0),
Content::Group { items } => v["children"] = Value::Array(items.iter().map(|c| item_json(c)).collect()),

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{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: package parts missing, font files skipped}",
"{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}",
always, open_idml),
]
}
@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
@ -34,11 +34,6 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Document> {
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<String>)> {
#[cfg(not(target_arch = "wasm32"))]
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
let read = |link: &str| -> Option<Vec<u8>> {
@ -54,8 +49,7 @@ pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Doc
None
}
};
let designcraft_idml::Imported { document, warnings } =
designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let document = {
let mut document = document;
@ -65,22 +59,13 @@ pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Doc
std::sync::Arc::make_mut(asset).link = Some(path);
}
}
resolve_packaged_links(&mut document, dir);
document
};
Ok((document, warnings))
Ok(document)
}
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_PATH_BYTES: usize = 128 * 1024;
#[cfg(not(target_arch = "wasm32"))]
fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
// Enough for a maximum-length Windows path even with four-byte UTF-8 characters.
// Bound splitting/allocation for link strings supplied by imported documents.
if link.len() > MAX_LINK_PATH_BYTES {
return Vec::new();
}
fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
let mut candidates = vec![std::path::PathBuf::from(link)];
if let Some(dir) = dir {
// IDML may retain Windows paths even when the package is opened on another platform.
@ -102,29 +87,7 @@ fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec<st
candidates.push(dir.join("Links").join(name));
}
}
candidates
}
/// Embedded images do not call the IDML resource loader. Rebind their missing links
/// beside the opened document too, without replacing their embedded image bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) {
let Some(dir) = dir else { return };
for asset in document.assets.values_mut() {
let Some(link) = asset.link.as_deref() else { continue };
if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() {
continue;
}
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue };
// Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
let path = std::path::absolute(&path).unwrap_or(path);
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
for path in packaged_link_candidates(link, dir) {
for path in candidates {
if let Ok(bytes) = std::fs::read(&path) {
// Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
// would introduce a verbatim prefix that is not an IDML file URI.
@ -148,15 +111,14 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?;
let mut d = import(&bytes, dir.as_deref())?;
if let Some(n) = name {
d.title = n;
}
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
let (fonts, faces, 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;
@ -170,7 +132,7 @@ mod tests {
use super::*;
fn linked_idml(link: &str, embed_images: bool) -> Result<(Vec<u8>, Vec<u8>)> {
fn linked_idml(link: &str) -> Result<(Vec<u8>, Vec<u8>)> {
let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png();
let mut session = Session::new();
session.execute("file.new", &json!({}))?;
@ -180,7 +142,7 @@ mod tests {
Arc::make_mut(asset).link = Some(link.into());
Ok(Value::Null)
})?;
let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images });
let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images: false });
Ok((bytes, png))
}
@ -189,7 +151,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id()));
let path = dir.join("Links").join("illustrations").join("logo.png");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png", false).unwrap();
let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png").unwrap();
std::fs::write(&path, &png).unwrap();
std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap();
@ -207,7 +169,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("dc-idml-flat-links-{}", std::process::id()));
let path = dir.join("Links").join("logo.png");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap();
let (idml, png) = linked_idml("/original-computer/project/logo.png").unwrap();
std::fs::write(&path, &png).unwrap();
let doc = import(&idml, Some(&dir)).unwrap();
@ -217,49 +179,4 @@ mod tests {
assert_eq!(super::super::links::status(asset), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn embedded_link_resolution_preserves_originals_and_image_bytes() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-embedded-links-{}-{nonce}", std::process::id()));
let original = dir.join("original").join("logo.png");
let package = dir.join("package");
let copy = package.join("Links").join("logo.png");
std::fs::create_dir_all(original.parent().unwrap()).unwrap();
std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap();
std::fs::write(&original, b"modified original").unwrap();
std::fs::write(&copy, &png).unwrap();
let doc = import(&idml, Some(&package)).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
assert_eq!(*asset.data, png);
assert_eq!(super::super::links::status(asset), "modified");
std::fs::remove_file(&original).unwrap();
std::fs::write(&copy, b"modified packaged copy").unwrap();
let doc = import(&idml, Some(&package)).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(asset.link.as_deref(), copy.to_str());
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
assert_eq!(super::super::links::status(asset), "modified");
std::fs::remove_file(&copy).unwrap();
for folder in [Some(package.as_path()), None] {
let doc = import(&idml, folder).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
assert_eq!(*asset.data, png);
assert_eq!(super::super::links::status(asset), "missing");
}
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty());
let mut doc = import(&idml, None).unwrap();
Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone());
resolve_packaged_links(&mut doc, Some(&package));
let asset = doc.assets.values().next().unwrap();
assert_eq!(asset.link.as_deref(), Some(oversized.as_str()));
assert_eq!(*asset.data, png);
std::fs::remove_dir_all(dir).unwrap();
}
}

View File

@ -759,35 +759,6 @@ mod setup_tests {
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::<Vec<_>>(), vec![(Left, 0.0), (Right, w)]);
}
/// Multi-column frames filled the left column first in a right-to-left document (#100):
/// stories made in a right-to-left-bound document start their columns on the right.
#[test]
fn new_stories_follow_the_binding_direction() {
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::<Vec<_>>().join("\n");
for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap();
s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
let st = s.doc().unwrap();
assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}");
let cs = s.cache.get(&st.doc, sid, None);
let fr = &cs.frames[0];
let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap());
assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns);
assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column");
// An empty frame given text content starts a story too.
let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap();
s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap();
let d = &s.doc().unwrap().doc;
let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content");
}
}
}
#[cfg(test)]

View File

@ -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()))?;
crate::atomic::write_file(p, &bytes)?;
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
}
Ok(())
}
@ -86,12 +86,10 @@ pub fn specs() -> Vec<CommandSpec> {
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 mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let index = lib.items.len();
let lib = s.library.as_mut().ok_or_else(no_library)?;
lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) });
lib.save()?;
s.library = Some(lib);
Ok(json!({"index": index}))
Ok(json!({"index": lib.items.len() - 1}))
}),
cmd!(
"library.place",
@ -109,14 +107,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| {
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let lib = s.library.as_mut().ok_or_else(no_library)?;
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| {

View File

@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|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, "story.placeAndLink")?;
let sr = spread_param(p, "spread");
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if d.story(parent).is_none() {

View File

@ -112,10 +112,8 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
}
fn list(d: &Document) -> Vec<Value> {
let used = d.used_assets();
d.assets
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = uses(d, a.id)
.into_iter()
@ -313,118 +311,3 @@ 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<u8> {
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::<Vec<_>>();
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<u8>| 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);
}
}

View File

@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
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, "guide.liquid")?;
let sr = super::spread_param(p, "spread");
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);

View File

@ -13,16 +13,11 @@ 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 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 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 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));
@ -70,7 +65,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
});
}
let text = s.doc()?.selection.text.filter(|t| t.cell.is_none());
let spread = super::spread_param(p, "math.insert")?;
let spread = super::spread_param(p, "spread");
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 {

View File

@ -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, "file.place")?;
let spread = super::spread_param(p, "spread");
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 fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None });
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 id = match target {
Some(fid) => {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
@ -90,9 +90,10 @@ pub fn specs() -> Vec<CommandSpec> {
s.edit(|d, _| {
let poster_id = match poster {
Some(Some((bytes, name, link, px))) => {
let fresh = AssetId(d.alloc());
let aid = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })))
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(None) => Some(None),
None => None,

View File

@ -50,7 +50,6 @@ mod strokes;
mod style;
pub mod table;
pub mod text;
mod thread;
mod toc;
mod transitions;
mod variables;
@ -185,7 +184,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());
v.extend(thread::specs());
v.extend(style::specs());
v.extend(table::specs());
v.extend(layout::specs());
@ -256,23 +254,12 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option<Rect> {
let f = |i: usize| a.get(i).and_then(Value::as_f64);
Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?))
}
/// 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<SpreadRef> {
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`")),
/// `{"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),
}
}

View File

@ -3,7 +3,7 @@
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> {
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = super::text_param(p, "text");
let text = str_param(p, "text").unwrap_or("").to_string();
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}")))?;

File diff suppressed because it is too large Load Diff

View File

@ -1,194 +1,15 @@
//! File › Package and Links › Copy Links To: gather a document with the files it uses.
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::fs::File;
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
use designcraft_compose::ComposedStory;
use designcraft_doc::{AssetId, Document};
use designcraft_fonts::{FaceRef, FontFace, FontSource};
use serde_json::{Value, json};
const MAX_OUTPUT_COMPONENT_BYTES: usize = 200;
fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{}: {message}", path.display()))
}
fn is_redirect(metadata: &Metadata) -> bool {
// Rust reports Windows name-surrogate reparse points (including directory junctions) as
// symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone.
metadata.file_type().is_symlink()
}
fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if is_redirect(&metadata) {
// An existing folder above the package may be reached through a link the user
// set up (`/tmp` on macOS is one); the package folder itself may not.
if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
return Ok(());
}
return Err(path_error(path, "refusing a redirected output directory"));
}
if !metadata.is_dir() {
return Err(path_error(path, "output path is not a directory"));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty())
&& parent != path
{
ensure_real_directory(parent, true)?;
}
if let Err(e) = std::fs::create_dir(path)
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
return Err(path_error(path, e));
}
let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?;
if is_redirect(&metadata) || !metadata.is_dir() {
return Err(path_error(path, "output directory was redirected while it was being created"));
}
}
Err(e) => return Err(path_error(path, e)),
}
Ok(())
}
/// A selected output directory and its resolved identity. Every package path is checked against
/// it before use. Outputs are staged in the same directory and atomically persisted over an
/// existing regular file, preserving overwrite behavior without following file links.
struct OutputRoot {
path: PathBuf,
canonical: PathBuf,
}
impl OutputRoot {
fn new(path: PathBuf) -> Result<Self> {
ensure_real_directory(&path, false)?;
let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?;
Ok(Self { path, canonical })
}
fn validate_directory(&self, path: &Path) -> Result<()> {
if !path.starts_with(&self.path) {
return Err(path_error(path, "output path is outside the selected directory"));
}
ensure_real_directory(path, false)?;
let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?;
if !canonical.starts_with(&self.canonical) {
return Err(path_error(path, "output directory resolves outside the selected directory"));
}
Ok(())
}
fn child(&self, name: &str) -> Result<PathBuf> {
let path = self.path.join(name);
if path.parent() != Some(self.path.as_path()) {
return Err(path_error(&path, "invalid output directory name"));
}
Ok(path)
}
fn validate_file_entry(&self, path: &Path) -> Result<bool> {
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
self.validate_directory(parent)?;
if !path.starts_with(&self.path) {
return Err(path_error(path, "output file is outside the selected directory"));
}
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if is_redirect(&metadata) {
return Err(path_error(path, "refusing to replace a redirected output file"));
}
if !metadata.is_file() {
return Err(path_error(path, "output path is not a regular file"));
}
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(path_error(path, e)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn temporary_file(&self, path: &Path, permissions: Option<std::fs::Permissions>) -> Result<tempfile::NamedTempFile> {
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
self.validate_directory(parent)?;
let mut builder = tempfile::Builder::new();
builder.prefix(".designcraft-package-").suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Match fs::write for a new output: 0666 restricted by the caller's umask.
builder.permissions(std::fs::Permissions::from_mode(0o666));
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
if let Some(permissions) = permissions {
// Overwrites retain their prior mode; copied fonts retain their source mode.
file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?;
}
#[cfg(not(unix))]
// The replacement inherits its directory's ACL. Rust's portable permission API cannot
// reproduce a destination file's Windows DACL without platform-specific code.
let _ = permissions;
Ok(file)
}
fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
{
let _ = (path, bytes);
return Err(EngineError::Other("package files are unavailable on the web".into()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let existed = self.validate_file_entry(path)?;
let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None };
let mut file = self.temporary_file(path, permissions)?;
file.write_all(bytes).map_err(|e| path_error(path, e))?;
// Recheck immediately before replacement. If a name is raced after this check,
// persist replaces that directory entry rather than following it.
self.validate_file_entry(path)?;
file.persist(path).map_err(|e| path_error(path, e.error))?;
Ok(())
}
}
fn copy(&self, from: &Path, to: &Path) -> Result<()> {
if self.validate_file_entry(to)?
&& std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target))
{
return Ok(());
}
#[cfg(target_arch = "wasm32")]
{
let _ = from;
return Err(EngineError::Other("package files are unavailable on the web".into()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut source = File::open(from).map_err(|e| path_error(from, e))?;
let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions());
let mut target = self.temporary_file(to, permissions)?;
std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?;
self.validate_file_entry(to)?;
target.persist(to).map_err(|e| path_error(to, e.error))?;
Ok(())
}
}
}
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@ -213,57 +34,14 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn is_reserved_windows_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']);
["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved))
|| (1..=9).any(|n| {
caseless::compatibility_caseless_match_str(stem, &format!("com{n}"))
|| caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}"))
})
}
fn portable_names_equal(a: &str, b: &str) -> bool {
caseless::canonical_caseless_match_str(a, b)
}
/// A portable single path component made from document metadata.
fn safe_component(name: &str, fallback: &str) -> String {
let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("");
let mut safe = String::new();
for ch in leaf.trim().chars() {
let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\');
let ch = if replacement { '_' } else { ch };
if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES {
break;
}
safe.push(ch);
}
safe.truncate(safe.trim_end_matches([' ', '.']).len());
if safe.is_empty() || safe == "." || safe == ".." {
safe = fallback.to_string();
}
if is_reserved_windows_name(&safe) {
safe.insert(0, '_');
}
safe
}
fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result<PathBuf> {
let path = dir.join(format!("{title}.{extension}"));
if path.parent() != Some(dir) {
return Err(bad("file.package", "an output filename would be outside the selected directory"));
}
Ok(path)
}
/// A file name for asset `a` in a Links folder, unique among `taken`.
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
let fallback = format!("asset-{}", id.0);
unique_name(safe_component(name, &fallback), taken)
let base =
Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0));
unique_name(base, taken)
}
/// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and
/// case-insensitive comparison.
/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively).
fn unique_name(base: String, taken: &mut Vec<String>) -> String {
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
@ -271,7 +49,7 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String {
};
let mut n = base;
let mut k = 2;
while taken.iter().any(|t| portable_names_equal(t, &n)) {
while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) {
n = format!("{stem} {k}{ext}");
k += 1;
}
@ -280,21 +58,21 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String {
}
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> {
root.validate_directory(dir)?;
fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> {
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut out = d.clone();
let mut taken = Vec::new();
let mut written = Vec::new();
// Only files something places; the saved document leaves the others out.
let ids = d.used_assets();
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
ids.sort_by_key(|i| i.0);
for id in ids {
if only.is_some_and(|o| !o.contains(&id)) {
continue;
}
let Some(a) = d.assets.get(&id) else { continue };
let a = &d.assets[&id];
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())?;
std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
let mut na = (**a).clone();
na.link = Some(path.to_string_lossy().to_string());
out.assets.insert(id, Arc::new(na));
@ -306,7 +84,7 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
/// Copy the font file of `face` into `dir` (created when first needed) unless its licence
/// restricts it or it is already there (`copied`: the source files copied) → what the package
/// report adds after the font's name (nothing when copied).
fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
let from = match &face.source {
FontSource::Bundled => return " — included with DesignCraft".into(),
FontSource::Memory => return " — not copied: no font file".into(),
@ -318,10 +96,13 @@ fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<Pa
if copied.contains(from) {
return String::new();
}
let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font");
let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into());
let to = dir.join(unique_name(name, taken));
match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) {
Ok(()) => {
// Packaging into the folder the font is in: it is there already (and copying a file onto
// itself empties it on some systems).
let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t));
match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) {
Ok(_) => {
copied.push(from.clone());
files.push(to);
String::new()
@ -355,22 +136,18 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
let st = s.doc()?;
let root = OutputRoot::new(dir.clone())?;
let display_title = st.doc.title.clone();
let title = safe_component(&display_title, "Untitled");
let title = st.doc.title.clone();
let fonts = s.execute("font.list", &json!({}))?;
let links = s.execute("links.list", &json!({}))?;
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
let d = s.doc()?.doc.clone();
let links_dir = root.child("Links")?;
let (packed, mut files) = write_links(&d, &root, &links_dir, None)?;
let doc_path = package_output_path(&dir, &title, "designcraft")?;
root.write(&doc_path, &super::to_bytes(&packed))?;
let (packed, mut files) = write_links(&d, &dir.join("Links"), None)?;
let doc_path = dir.join(format!("{title}.designcraft"));
std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?;
files.insert(0, doc_path);
// The font files, and what the report says about each font.
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?;
let (mut copied, mut taken) = (Vec::new(), Vec::new());
let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new());
let (mut font_lines, mut named) = (Vec::new(), Vec::new());
for f in fonts.as_array().into_iter().flatten() {
let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
@ -378,7 +155,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
// A missing font's text is drawn in its substitute, which isn't a fallback font.
let face = db.face(family, style);
named.push(face.id());
let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) };
let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) };
font_lines.push(format!(" {family} {style}{note}\n"));
}
// Fallback fonts: the faces that draw characters the named fonts lack.
@ -388,23 +165,21 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
}
drawn.retain(|f| !named.contains(&f.id()));
drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style)));
let fallback_lines: Vec<String> = drawn
.iter()
.map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files)))
.collect();
let fallback_lines: Vec<String> =
drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect();
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
let path = package_output_path(&dir, &title, "idml")?;
root.write(&path, &designcraft_idml::export_idml(&packed))?;
let path = dir.join(format!("{title}.idml"));
std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
files.push(path);
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
let path = package_output_path(&dir, &title, "pdf")?;
let path = dir.join(format!("{title}.pdf"));
let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
root.write(&path, &bytes)?;
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
files.push(path);
}
// The report: fonts (missing first), links, preflight, the user's instructions.
let mut r = format!("Package report: {display_title}\n\n");
let mut r = format!("Package report: {title}\n\n");
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
r += &format!("Instructions\n{t}\n\n");
}
@ -425,7 +200,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let report = dir.join("Instructions.txt");
root.write(&report, r.as_bytes())?;
std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?;
files.push(report);
Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r}))
}
@ -434,8 +209,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
let only: Option<Vec<AssetId>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect());
let d = s.doc()?.doc.clone();
let root = OutputRoot::new(dir.clone())?;
let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?;
let (relinked, written) = write_links(&d, &dir, only.as_deref())?;
s.edit(|doc, _| {
doc.assets = relinked.assets;
Ok(())
@ -447,31 +221,6 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn relocated_packages_rebind_embedded_image_links_on_open() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id()));
let original = dir.join("original");
let moved = dir.join("moved");
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap();
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap();
s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
std::fs::rename(&original, &moved).unwrap();
let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap();
for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] {
let mut opened = Session::new();
opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap();
let asset = opened.doc().unwrap().doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels");
assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file");
let links = opened.execute("links.list", &json!({})).unwrap();
assert_eq!(links[0]["status"], "ok", "{extension}");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
@ -517,12 +266,6 @@ mod tests {
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap();
std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap();
}
// Text in both fonts, and in the bundled default font.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
@ -540,15 +283,6 @@ mod tests {
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap();
let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER);
assert!(packed_fonts.join("free.ttf").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777,
std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777
);
}
assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out");
assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]);
let report = r["report"].as_str().unwrap();
@ -610,142 +344,4 @@ mod tests {
assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_uses_portable_names_inside_the_selected_directory() {
assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review");
assert_eq!(safe_component(".", "Untitled"), "Untitled");
assert_eq!(safe_component("CON", "Untitled"), "_CON");
assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt");
assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³");
for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] {
assert!(is_reserved_windows_name(name), "{name}");
}
let mut taken = Vec::new();
assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg");
assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg");
assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf");
assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf");
assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt");
assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt");
let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap();
let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
let files = r["files"].as_array().unwrap();
assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir)));
assert!(dir.join("Annual_ Review.designcraft").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_overwrites_existing_regular_outputs() {
let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let document = dir.join("Annual.designcraft");
std::fs::write(&document, b"previous package").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap();
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok());
assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_accepts_a_relative_destination() {
let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Relative"})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
assert!(dir.join("Relative.designcraft").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn package_refuses_redirected_destinations() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
let redirected_root = base.join("redirected-package");
symlink(&outside, &redirected_root).unwrap();
assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_file(&redirected_root).unwrap();
// A linked folder above the package is followed, as `/tmp` is on macOS.
let linked_parent = base.join("linked-parent");
symlink(&outside, &linked_parent).unwrap();
let nested_package = linked_parent.join("nested").join("Package");
s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap();
assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file());
std::fs::remove_file(&linked_parent).unwrap();
let package = base.join("Package");
std::fs::create_dir_all(&package).unwrap();
symlink(&outside, package.join("Links")).unwrap();
assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_file(package.join("Links")).unwrap();
std::fs::create_dir(package.join("Links")).unwrap();
let kept = outside.join("kept.designcraft");
std::fs::write(&kept, b"kept").unwrap();
symlink(&kept, package.join("Annual.designcraft")).unwrap();
assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err());
assert_eq!(std::fs::read(&kept).unwrap(), b"kept");
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(windows)]
#[test]
fn package_refuses_windows_directory_redirects() {
use std::os::windows::fs::symlink_dir;
let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let redirected = base.join("Package");
match symlink_dir(&outside, &redirected) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let _ = std::fs::remove_dir_all(&base);
return;
}
Err(e) => panic!("could not create directory redirect: {e}"),
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_dir(&redirected).unwrap();
let _ = std::fs::remove_dir_all(&base);
}
}

View File

@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{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",
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}",
has_doc,
path_options
),
@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result<Option<PathAlign>> {
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?;
let text = super::text_param(p, "text");
let text = super::str_param(p, "text").unwrap_or("").to_string();
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),
@ -54,7 +54,6 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
}
let sid = StoryId(d.alloc());
let mut st = Story::with_text(sid, &text, Default::default());
st.direction = d.new_story_direction();
st.frames = vec![id];
d.stories.insert(sid, std::sync::Arc::new(st));
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
@ -86,7 +85,6 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
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 {
@ -96,26 +94,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
sel.text = None;
return Ok(json!({"deleted": true}));
}
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("start").and_then(Value::as_f64) {
pt.start = v.max(0.0);
}
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
pt.flip = v;
@ -123,7 +103,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = align {
pt.align = a;
}
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
})
}
@ -179,93 +159,4 @@ 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:?}");
}
}
}

View File

@ -105,44 +105,28 @@ fn strip(story: &mut Story) {
story.format_chars(0..n, |f| *f = Default::default());
}
/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
/// A frame a column, frame or page break ends early is left out: it says nothing about how much
/// a frame holds.
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
/// Is the story overset in its frames, and how much of it do its frames show?
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize) {
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
let text = d.story(sid).map_or("", |s| s.text.as_str());
let is_break = |c: char| matches!(c, designcraft_doc::COLUMN_BREAK | designcraft_doc::FRAME_BREAK | designcraft_doc::PAGE_BREAK);
let (mut held, mut full) = (0, 0);
for f in &cs.frames {
let Some(shown) = text.get(f.range.clone()) else { continue };
if !shown.is_empty() && !shown.chars().any(is_break) {
held += shown.len();
full += 1;
}
}
(cs.overset_at, held, full)
let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0);
(cs.overset_at, shown)
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result<usize> {
let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) {
return Err(bad(cmd, "autoflow works only from a frame on a document page"));
}
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
let mut added = 0;
loop {
let (overset, held, full) = fit(d, sid);
let (overset, shown) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
// Frames still needed at the rate the full ones are filled (at least one); with no full
// frame to go by yet, one more page.
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1);
// Frames still needed at the rate the existing ones are filled (at least one).
let per_frame = (shown.max(at) / frames).max(1);
let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(page), want, parent)?;
@ -150,7 +134,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut prev = last;
for _ in 0..want {
page += 1;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
let r = d.spreads[si].pages[pi].margin_rect();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
d.thread(prev, fid)?;
@ -216,7 +200,6 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
Some(_) => {
let sid = StoryId(d.alloc());
let mut ns = Story::new(sid);
ns.direction = d.new_story_direction();
ns.frames = vec![fid];
d.stories.insert(sid, std::sync::Arc::new(ns));
if let Some(it) = d.item_mut(fid) {
@ -236,7 +219,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
*sel = designcraft_doc::Selection::items(vec![fid]);
sid
};
let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 };
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
let overset = fit(d, sid).0.is_some();
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
})
@ -274,29 +257,6 @@ mod tests {
assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
// holds a few characters, and add a page per few characters left (33 for this text).
let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (name, rtf, pages) in [
("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2),
("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4),
] {
let path = dir.join(name);
std::fs::write(&path, rtf).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{name}: {r}");
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), pages, "{name}: {r}");
assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]

View File

@ -5,7 +5,6 @@
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};
@ -30,12 +29,10 @@ pub fn specs() -> Vec<CommandSpec> {
"{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)]
}
fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usize> {
fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
let SpreadRef::Doc(si) = sr else { return None };
let sp = d.spreads.get(si)?;
// 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))
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
}
/// Characters a missing-glyph report names before "and N more".
@ -88,7 +85,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
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, Affine::IDENTITY));
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it));
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 });
}
@ -164,52 +161,48 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
page: None,
});
}
// Carry the ancestor transform in the same order as rendering, retaining pre-order.
// Items.
for (si, sp) in d.spreads.iter().enumerate() {
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,
});
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,
});
}
}
}
},
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.

View File

@ -1,13 +1,9 @@
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
use serde_json::{Value, json};
#[cfg(not(target_arch = "wasm32"))]
use super::str_param;
use super::{CommandSpec, bad, cmd, has_doc};
#[cfg(not(target_arch = "wasm32"))]
use crate::EngineError;
use crate::{Result, Session};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@ -17,60 +13,71 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
has_doc,
print
),
]
}
#[cfg(not(target_arch = "wasm32"))]
fn printers() -> Value {
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();
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<String> = 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);
json!({"printers": names, "default": default})
}
#[cfg(target_arch = "wasm32")]
fn printers() -> Value {
json!({"printers": [], "default": null})
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
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);
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 file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
let mut cmd: Vec<String> = 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.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);
}
#[cfg(target_arch = "wasm32")]
{
super::export::options(p, s.doc()?.doc.page_count())?;
let _ = pdf;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
print_with(s, p, craft_print::submit)
}
#[cfg(not(target_arch = "wasm32"))]
fn print_with(
s: &mut Session,
p: &Value,
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
) -> Result<Value> {
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()))?;
{
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.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)
}
Ok(out)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::Session;
#[test]
fn print_builds_the_spool_command() {
@ -78,45 +85,11 @@ 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 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");
}
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!(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());
}
#[test]
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(&params, d.page_count()).unwrap();
designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap()
};
let r = print_with(&mut s, &params, |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, &params, |_, _| 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());
}
}

View File

@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result<Value> {
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, ID)?;
let sr = spread_param(p, "spread");
let st = s.doc()?;
let lid = st.active_layer;
let sel = st.selection.items.clone();

View File

@ -95,29 +95,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}",
"{name, rename?, basedOn?, nextStyle?, para?: {…}, chars?: {…}}",
has_doc,
edit_para
),
cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
cmd!(
"style.character.create",
"New Character Style…",
[],
None,
"{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
),
cmd!(
"style.character.edit",
"Character Style Options…",
[],
None,
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
has_doc,
edit_char
),
cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
@ -180,19 +164,10 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Color",
[],
None,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
has_text_or_selection,
"{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,
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",
@ -453,7 +428,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
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, _| {
@ -479,8 +453,8 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
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, _| {
@ -489,9 +463,6 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
}
// A rule object changes only the rule fields it names, over the style's resolved rule.
let (current, _) = d.styles.resolve_para_style(&name);
let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &current))?;
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
@ -536,12 +507,6 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
s.based_on = Some(to.to_string());
}
}
for s in &mut st.paragraph {
rename_char_refs(&mut s.para, from, to);
}
if d.footnote_options.ref_char_style == from {
d.footnote_options.ref_char_style = to.to_string();
}
}
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(story) = d.story_mut(sid) else { continue };
@ -554,26 +519,12 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
for r in st.chars.iter_mut().filter(|r| r.format.style == from) {
r.format.style = to.to_string();
}
for f in &mut st.paras {
rename_char_refs(&mut f.para, from, to);
}
}
st.rev += 1;
});
}
}
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) {
if n == from {
*n = to.to_string();
}
}
}
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
let para = match str_param(p, "kind").unwrap_or("paragraph") {
"paragraph" => true,
@ -631,34 +582,9 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
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()?;
// Match paragraph fromSelection's first-character/caret policy, but keep paragraph
// attributes inherited instead of freezing the fully resolved character properties.
let format = super::text::effective_char_format(s, target)?;
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);
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let based_on = str_param(p, "basedOn").map(str::to_string);
s.edit(|d, _| {
if let Some(b) = &based_on
&& d.styles.char_style(b).is_none()
{
return Err(bad("style.character.create", format!("no character style `{b}`")));
}
let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
Ok(json!({"name": name}))
@ -667,51 +593,10 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
if name == designcraft_doc::NO_CHAR_STYLE {
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<String> = p
.get("chars")
.and_then(Value::as_object)
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
.unwrap_or_default();
let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string());
let based: Option<Option<String>> = match p.get("basedOn") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None),
Some(Value::String(b)) => Some(Some(b.clone())),
Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")),
};
s.edit(|d, _| {
if let Some(Some(b)) = &based {
if d.styles.char_style(b).is_none() {
return Err(bad("style.character.edit", format!("no character style `{b}`")));
}
if d.styles.char_based_on_cycles(&name, b) {
return Err(bad("style.character.edit", "based-on would create a cycle"));
}
}
if let Some(n) = &rename
&& (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some())
{
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
}
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?;
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
st.chars.merge(&chars);
for k in &unset {
st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?;
}
if let Some(b) = based.clone() {
st.based_on = b;
}
if let Some(n) = &rename {
// Every use: other styles, stories, table cells, nested and GREP styles, footnotes.
rename_style(d, false, &name, n);
}
ok()
})
}
@ -747,18 +632,13 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
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 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))
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)?))
}
Value::Object(o) => {
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))
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")?))
}
_ => None,
}
@ -871,11 +751,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = designcraft_color::ase::write(&chosen);
match str_param(p, "path") {
Some(path) => {
#[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}")))?;
std::fs::write(path, &bytes).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)})),
@ -982,146 +858,41 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
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 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());
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
if text && texts.is_empty() {
return Err(bad("object.color", "select the text to colour"));
}
// A caret: the colour is for the next typing (once the swatch exists).
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
let r = s.edit(|d, _| {
let name = color_swatch(d, color);
color_items(d, &ids, &name, stroke);
if !caret {
let a = if stroke {
CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
} else {
CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
};
super::text::apply_char_attrs(d, &texts, &a, &[]);
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
.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;
} else {
it.fill.swatch = name.clone();
it.fill.tint = 1.0;
}
}
}
Ok(json!({"swatch": name}))
})?;
if caret && let Some(name) = r.get("swatch").cloned() {
let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
super::text::format_chars(s, &attrs)?;
}
Ok(r)
}
/// 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<Value> {
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, &params)?;
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<Value> {
@ -1135,7 +906,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let rename = str_param(p, "name").map(str::to_string);
let result = s.edit(|d, _| {
s.edit(|d, _| {
let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?;
if w.locked {
return Err(bad("swatch.addToSwatches", "special swatches are already listed"));
@ -1162,84 +933,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
rename_in(std::sync::Arc::make_mut(top), &name, &new);
}
}
for st in d.stories.values_mut() {
let mut copy = (**st).clone();
if rename_in_story(&mut copy, &name, &new, 0)? {
*st = std::sync::Arc::new(copy);
}
}
Ok(json!({"name": new}))
})?;
if let Some(new) = result.get("name").and_then(Value::as_str) {
super::text::rename_typing_swatch(s, &name, new);
}
Ok(result)
}
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> {
// Refuse the whole edit instead of leaving deeper references pointing at a removed name.
if depth > 32 {
return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels"));
}
fn fix(s: &mut String, from: &str, to: &str) -> bool {
let hit = s == from;
if hit {
*s = to.to_string();
}
hit
}
let mut n = 0;
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
for r in &mut st.chars {
let o = &mut r.format.over;
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
f(c);
}
}
for pf in &mut st.paras {
let a = &mut pf.para;
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
f(c);
}
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
f(&mut r.color);
}
}
let mut nested = false;
for t in st.tables.values_mut() {
let t = std::sync::Arc::make_mut(t);
let o = &mut t.options;
f(&mut o.border.color);
for b in o.borders.iter_mut().flatten() {
f(&mut b.color);
}
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
f(&mut a.first_color);
f(&mut a.next_color);
}
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
f(&mut k.first_stroke.color);
f(&mut k.next_stroke.color);
}
for c in &mut t.cells {
f(&mut c.fill);
for k in &mut c.strokes {
f(&mut k.color);
}
nested |= rename_in_story(&mut c.text, from, to, depth + 1)?;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?;
}
let changed = n > 0 || nested;
if changed {
st.rev = st.rev.wrapping_add(1);
}
Ok(changed)
})
}
/// Point fills and strokes of an item (and its children) at a renamed swatch.
@ -1261,168 +956,6 @@ 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, &params);
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", &params).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();
@ -1444,73 +977,6 @@ mod color_tests {
assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err());
}
#[test]
fn character_style_edit_sets_unsets_rebases_and_renames_every_use() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap();
s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap();
s.execute(
"style.paragraph.create",
&json!({"name": "Lead", "para": {
"nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}],
"grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}],
"nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}),
)
.unwrap();
s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
s.execute(
"style.character.edit",
&json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.styles.char_style("Emphasis").is_none());
let st = d.styles.char_style("Strong").unwrap();
assert_eq!(st.based_on.as_deref(), Some("Base"));
// Exactly the attributes set: `null` unset the font style, the rest stayed unset.
assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() });
let story = d.story(designcraft_doc::StoryId(sid)).unwrap();
assert!(story.chars.iter().any(|r| r.format.style == "Strong"));
assert!(story.chars.iter().all(|r| r.format.style != "Emphasis"));
let lead = d.styles.para("Lead").unwrap();
assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(d.footnote_options.ref_char_style, "Strong");
// Based On [None] (null or the name) clears it.
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
// Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None].
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err());
assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty());
}
#[test]
fn new_character_style_checks_its_based_on() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err());
s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap();
let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap();
assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() }));
}
#[test]
fn swatches_save_and_load_ase() {
let mut s = Session::new();
@ -1553,40 +1019,6 @@ mod color_tests {
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
}
#[test]
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
use std::sync::Arc;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
// The same unnamed colour on a cell, the cell's text and the footnote's text.
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
t.cells[0].fill = old.clone();
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
Ok(())
})
.unwrap();
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(sid).unwrap();
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
let cell = &st.tables.values().next().unwrap().cells[0];
assert_eq!(cell.fill, "Sand");
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
d.check().unwrap();
}
}
#[cfg(test)]
@ -1735,116 +1167,3 @@ 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_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 sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]);
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing");
let undo = s.doc().unwrap().history.undo.len();
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}");
}
}
}
#[cfg(test)]
#[path = "text_color_tests.rs"]
mod text_color_tests;

View File

@ -178,7 +178,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Horizontally",
["Table"],
None,
"{} — each target cell becomes two, one above the other",
"{} — the target cell becomes two, one above the other",
in_table,
|s, p| split(s, p, true)
),
@ -187,7 +187,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Vertically",
["Table"],
None,
"{} — each target cell becomes two, side by side",
"{} — the target cell becomes two, side by side",
in_table,
|s, p| split(s, p, false)
),
@ -203,15 +203,6 @@ fn table_specs() -> Vec<CommandSpec> {
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
cmd!(
"table.distributeRows",
"Distribute Rows Evenly",
["Table"],
None,
"{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first",
in_table,
distribute_rows
),
cmd!(
"table.options",
"Table Options",
@ -381,14 +372,6 @@ fn column_width(s: &Session, t: &TextSel) -> f64 {
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
}
/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style
/// chain, then local overrides). A table created there takes it.
fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection {
d.story(story)
.and_then(|st| st.paras.get(st.para_at(offset)))
.map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
@ -402,7 +385,6 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|d, sel| {
let id = d.alloc();
let mut table = Table::new(id, rows, cols, header, footer, width);
table.options.direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
// Cells start with the paragraph format at the caret.
let pf = st.paras[st.para_at(t.range().start)].clone();
@ -428,7 +410,6 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
let ranges = st.para_ranges();
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
@ -439,8 +420,7 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
table.options.direction = direction;
let table = Table::from_strings(id, &data, width, &pf, &cf);
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
@ -589,7 +569,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let g = target(s, p, "table.split")?;
edit_table(s, &g, "table.split", |t| {
t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?;
t.split_cell(g.range.r0, g.range.c0, horizontal);
Ok(json!({"rows": t.nrows(), "cols": t.ncols()}))
})
}
@ -645,16 +625,11 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))),
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let text = str_param(p, "text").map(str::to_string);
let stroke = p.get("stroke").cloned();
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();
let nc = t.ncols();
let nr = t.nrows();
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone());
// Local edits outrank previously imported edges, including explicit zero/None.
let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 };
let rg = g.range;
for r in rg.r0..=rg.r1 {
for c in rg.c0..=rg.c1 {
@ -680,57 +655,40 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
let at_bottom = r.saturating_add(cell.row_span.max(1) as usize);
let at_right = c.saturating_add(cell.col_span.max(1) as usize);
let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }];
for (i, on) in [
("top", r == rg.r0),
("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }),
("bottom", at_bottom.saturating_sub(1) == rg.r1),
("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }),
("left", c == rg.c0),
("bottom", r + cell.row_span as usize - 1 == rg.r1),
("right", c + cell.col_span as usize - 1 == rg.c1),
]
.iter()
.enumerate()
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
{
if on {
let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] };
cell.strokes[i] = parse_stroke(sv, base.clone());
cell.stroke_defined[i] = true;
cell.stroke_priorities[i] = priority;
cell.border_overrides[i] = true;
cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone());
}
}
}
}
}
// Keep both copies of unmerged shared boundaries consistent. Left/right name
// physical sides, also in RTL tables.
// Neighbouring cells share edges: mirror the edges along the range boundary.
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
let selected = |side: &str| edges == "all" || edges == "outer" || edges == side;
if selected("top")
&& let Some(row) = rg.r0.checked_sub(1)
{
if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() {
for c in rg.c0..=rg.c1 {
mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2);
let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) {
n.strokes[0] = s0;
}
}
}
if selected("bottom") && rg.r1.saturating_add(1) < nr {
for c in rg.c0..=rg.c1 {
mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0);
}
}
if selected(if rtl { "right" } else { "left" })
&& let Some(col) = rg.c0.checked_sub(1)
{
if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() {
for r in rg.r0..=rg.r1 {
mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 });
}
}
if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc {
for r in rg.r0..=rg.r1 {
mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 });
let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) {
n.strokes[1] = s0;
}
}
}
}
@ -738,53 +696,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Only saturation needs rebasing. Preserve every distinct priority's order and keep
/// zero/negative priorities untouched; the work is bounded by the table's edges.
fn next_cell_stroke_priority(t: &mut Table) -> i32 {
let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0);
if highest < i32::MAX {
return highest + 1;
}
let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect();
let mut ranks = std::collections::BTreeMap::new();
let mut highest = 0i32;
for value in values {
highest = highest.saturating_add(1);
ranks.insert(value, highest);
}
for cell in &mut t.cells {
for priority in &mut cell.stroke_priorities {
if let Some(rank) = ranks.get(priority) {
*priority = *rank;
}
}
}
highest.saturating_add(1)
}
fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) {
let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied();
if owner(from) != Some(from) || owner(to) != Some(to) {
return;
}
// A merged cell's edge may border several separately editable cells. Mirroring
// a segment onto that whole edge would change unselected neighbors; priorities
// instead resolve each segment without altering the merged cell's formatting.
let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1;
if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) {
return;
}
let source = t
.cell(from.0, from.1)
.map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge]));
if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) {
neighbor.strokes[opposite] = stroke;
neighbor.stroke_defined[opposite] = defined;
neighbor.stroke_priorities[opposite] = priority;
neighbor.border_overrides[opposite] = border_override;
}
}
fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setRowHeight")?;
let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0));
@ -821,63 +732,6 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.distributeRows";
if !p.is_object() {
return Err(bad(ID, "parameters must be an object"));
}
if let Some(rows) = p.get("rows") {
let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())));
if !valid {
return Err(bad(ID, "rows must be two non-negative row indices"));
}
}
for key in ["row", "story", "table"] {
if let Some(v) = p.get(key)
&& 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")));
}
}
let g = target(s, p, ID)?;
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
if t.cells.iter().any(|c| c.row_span > 1) {
return Err(bad(ID, "unmerge cells that span rows first"));
}
let cs = s.cache.get(&st.doc, g.story, None);
let mut total = 0.0;
for row in g.range.r0..=g.range.r1 {
let height = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.filter(|f| f.table == g.table)
.flat_map(|f| &f.cells)
.filter(|c| c.row == row)
.map(|c| c.rect.height())
.reduce(f64::max)
.ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?;
if !height.is_finite() || height <= 0.0 {
return Err(bad(ID, "invalid composed row height"));
}
total += height;
}
let count = g.range.r1 - g.range.r0 + 1;
let height = total / count as f64;
if !height.is_finite() {
return Err(bad(ID, "invalid total row height"));
}
edit_table(s, &g, ID, |t| {
let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?;
for row in rows {
row.height = height;
row.mode = RowHeightMode::Exactly;
}
Ok(json!({"rows": count, "height": height}))
})
}
fn distribute_cols(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.distributeColumns")?;
edit_table(s, &g, "table.distributeColumns", |t| {
@ -915,7 +769,6 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(b) = p.get("border") {
o.border = parse_stroke(b, o.border.clone());
o.borders = Default::default();
}
o.space_before = f64_or(&p, "spaceBefore", o.space_before);
o.space_after = f64_or(&p, "spaceAfter", o.space_after);
@ -1052,9 +905,6 @@ fn stroke_param(v: Option<&Value>) -> Option<designcraft_doc::CellStroke> {
/// Fill in the cell style fields given in `p`.
fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> {
if let Some(v) = p.get("basedOn") {
cs.based_on = v.as_str().map(str::to_string);
}
if let Some(f) = color_param(p, "fill") {
cs.fill = Some(f);
}
@ -1062,14 +912,10 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str)
cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32);
}
match p.get("insets") {
Some(Value::Number(n)) => {
cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]);
cs.inset_overrides = Default::default();
}
Some(Value::Number(n)) => cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]),
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
cs.insets = Some([v[0], v[1], v[2], v[3]]);
cs.inset_overrides = Default::default();
}
_ => {}
}
@ -1078,7 +924,6 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str)
}
if let Some(st) = stroke_param(p.get("stroke")) {
cs.stroke = Some(st);
cs.strokes = Default::default();
}
if let Some(ps) = str_param(p, "paragraphStyle") {
cs.paragraph_style = Some(ps.to_string());
@ -1115,11 +960,15 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string();
let styles = &s.doc()?.doc.styles.cell;
if !styles.iter().any(|c| c.name == name) {
return Err(bad("style.cell.apply", format!("no cell style `{name}`")));
}
let cs = designcraft_doc::resolve_cell_style(styles, &name);
let cs = s
.doc()?
.doc
.styles
.cell
.iter()
.find(|c| c.name == name)
.cloned()
.ok_or_else(|| bad("style.cell.apply", format!("no cell style `{name}`")))?;
let g = target(s, p, "style.cell.apply")?;
edit_table(s, &g, "style.cell.apply", |t| {
let owners = t.owners();
@ -1141,7 +990,7 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
/// Re-apply cell styles named in `names` to every cell using them (after a style edit).
fn reapply_cell_styles(d: &mut Document, names: &[String]) {
let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect();
let styles = d.styles.cell.clone();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
@ -1160,42 +1009,18 @@ fn reapply_cell_styles(d: &mut Document, names: &[String]) {
}
}
/// Include descendants when editing an inherited style, with the resolver's depth limit.
fn dependent_styles<'a>(name: &str, styles: impl Iterator<Item = (&'a str, Option<&'a str>)>) -> Vec<String> {
let styles: Vec<_> = styles.collect();
styles
.iter()
.filter(|(candidate, _)| {
let mut current = Some(*candidate);
for _ in 0..32 {
let Some(current_name) = current else { return false };
if current_name == name {
return true;
}
current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base);
}
false
})
.map(|(name, _)| (*name).to_string())
.collect()
}
fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string();
let p = p.clone();
s.edit(|d, _| {
let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?;
cell_style_fields(cs, &p, "style.cell.edit")?;
let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref())));
reapply_cell_styles(d, &names);
reapply_cell_styles(d, std::slice::from_ref(&name));
ok()
})
}
fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) {
if let Some(v) = p.get("basedOn") {
ts.based_on = v.as_str().map(str::to_string);
}
for (k, f) in [
("header", &mut ts.header),
("body", &mut ts.body),
@ -1209,7 +1034,6 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) {
}
if let Some(b) = stroke_param(p.get("border")) {
ts.border = Some(b);
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
@ -1249,10 +1073,7 @@ fn table_style_create(s: &mut Session, p: &Value) -> Result<Value> {
fn table_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string();
let doc = &s.doc()?.doc;
if !doc.styles.table.iter().any(|c| c.name == name) {
return Err(bad("style.table.apply", format!("no table style `{name}`")));
}
let ts = doc.styles.resolve_table_style(&name);
let ts = doc.styles.table.iter().find(|c| c.name == name).cloned().ok_or_else(|| bad("style.table.apply", format!("no table style `{name}`")))?;
let cells = doc.styles.cell.clone();
let g = target(s, p, "style.table.apply")?;
edit_table(s, &g, "style.table.apply", |t| {
@ -1268,13 +1089,11 @@ fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let ts =
d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?;
table_style_fields(ts, &p);
let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref())));
let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect();
let cells = d.styles.cell.clone();
let (ts, cells) = (ts.clone(), d.styles.cell.clone());
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) {
if t.style == name {
ts.apply_to(std::sync::Arc::make_mut(t), &cells);
}
}
@ -1402,10 +1221,20 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
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 fresh = designcraft_doc::AssetId(d.alloc());
let aid = designcraft_doc::AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
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)) });
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 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);
@ -1486,105 +1315,6 @@ mod sort_tests {
use crate::Session;
fn distribution_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] {
s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap();
s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap();
}
s
}
fn table(s: &Session) -> designcraft_doc::Table {
(**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone()
}
#[test]
fn distribute_selected_rows_preserves_height_content_and_undo() {
let mut s = distribution_session();
s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap();
let before = table(&s);
let selection = s.doc().unwrap().selection.cells;
let result = s.execute("table.distributeRows", &json!({})).unwrap();
assert_eq!(result, json!({"rows": 2, "height": 60.0}));
let after = table(&s);
assert_eq!(after.rows.iter().map(|r| r.height).collect::<Vec<_>>(), [25.0, 60.0, 60.0, 30.0]);
assert_eq!(after.cells, before.cells);
assert_eq!(after.rows[0].kind, before.rows[0].kind);
assert_eq!(s.doc().unwrap().selection.cells, selection);
let st = s.doc().unwrap();
let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id;
let cs = s.cache.get(&st.doc, sid, None);
let heights: Vec<_> =
cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect();
assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(table(&s), after);
}
#[test]
fn distribution_uses_content_grown_heights_and_explicit_table() {
let mut s = distribution_session();
s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap();
s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap();
let st = s.doc().unwrap();
let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap();
let (sid, tid) = (story.id, *story.tables.keys().next().unwrap());
let cs = s.cache.get(&st.doc, sid, None);
let total: f64 = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.flat_map(|f| &f.cells)
.filter(|c| c.col == 0 && (1..=2).contains(&c.row))
.map(|c| c.rect.height())
.sum();
let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap();
assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6);
assert!(table(&s).rows[1].height > 3.0);
assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly);
}
#[test]
fn distribution_keeps_column_spans_and_caret() {
let mut s = distribution_session();
s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();
let before = table(&s);
let caret = s.doc().unwrap().selection.text;
let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap();
assert_eq!(result["height"], 60.0);
assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2);
assert_eq!(table(&s).cells, before.cells);
assert_eq!(s.doc().unwrap().selection.text, caret);
}
#[test]
fn distribution_rejects_merges_and_missing_rows_without_changes() {
let mut s = distribution_session();
s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap();
let before = table(&s);
assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err());
assert_eq!(table(&s), before);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap();
let before = table(&s);
assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err());
assert_eq!(table(&s), before);
assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err());
for params in
[json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])]
{
assert!(s.execute("table.distributeRows", &params).is_err(), "{params}");
assert_eq!(table(&s), before);
}
}
#[test]
fn sort_body_rows_by_a_column() {
let mut s = Session::new();
@ -1705,46 +1435,4 @@ mod arabic_tests {
assert_eq!(t.ncols(), 2);
assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft);
}
/// A text frame whose paragraphs have a paragraph style with `direction`, with the story's
/// text selected. Returns the session and the story id.
fn styled_frame(direction: &str, text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
(s, r["story"].as_u64().unwrap())
}
fn only_table(s: &Session) -> std::sync::Arc<designcraft_doc::Table> {
s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone()
}
/// Text converted to a table in a right-to-left paragraph laid its columns out left to right
/// (#100): the first column belongs on the right.
#[test]
fn tables_take_the_direction_of_their_paragraph() {
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, RightToLeft);
let st = s.doc().unwrap();
let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None);
let placed = &cs.frames[0].tables[0];
let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect);
assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}");
let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, LeftToRight);
for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
let (mut s, _) = styled_frame(direction, "x");
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,181 +0,0 @@
//! Cross-command caret color and character-style regressions.
use crate::Session;
use designcraft_doc::StoryId;
use serde_json::{Value, json};
fn setup(text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap();
(s, sid)
}
fn attrs(s: &mut Session) -> Value {
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone()
}
#[test]
fn integration_color_character_style_captures_pending_and_null() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap();
s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap();
let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars;
assert_eq!(a.size, Some(30.0));
assert_eq!(a.fill.as_deref(), Some("[Paper]"));
assert_eq!(a.tracking, None);
assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello");
}
#[test]
fn integration_color_pending_follows_promoted_swatch() {
for target in ["fill", "stroke"] {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
assert_eq!(attrs(&mut s)[target], "Brand");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
let f = &st.format_after(5).over;
assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand"));
}
}
#[test]
fn integration_color_undo_discards_missing_pending_swatch() {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch(&old).is_none());
assert_ne!(attrs(&mut s)["fill"], old);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str()));
}
#[test]
fn integration_color_empty_story_uses_one_undo_step() {
let (mut s, sid) = setup("");
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("object.color", &json!({"color":"#123456"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some());
}
#[test]
fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() {
let (mut s, sid) = setup("ab\ncd");
for (name, fill, stroke, ft, st, start, end) in
[("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)]
{
s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap();
s.execute("style.paragraph.apply", &json!({"name":name})).unwrap();
}
s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
for (pos, fill, stroke, ft, st) in [
(0, "[Paper]", "[Black]", 0.2, 0.8),
(1, "[Black]", "[Paper]", 0.8, 0.2),
(2, "[Black]", "[Paper]", 0.8, 0.2),
(3, "[Paper]", "[Black]", 0.6, 0.4),
(4, "[Black]", "[Paper]", 0.4, 0.6),
] {
s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap();
let a = attrs(&mut s);
assert_eq!(a["fill"], fill);
assert_eq!(a["stroke"], stroke);
assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6);
assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_swap_caret_applies_only_to_next_typing() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
let a = attrs(&mut s);
assert_eq!(a["fill"], "[Black]");
assert_eq!(a["stroke"], "[Paper]");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
assert_eq!(st.text, "helloX");
assert_eq!(st.format_after(0).over.fill, None);
assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]"));
assert_eq!(st.format_after(5).over.fill_tint, Some(0.8));
}
#[test]
fn integration_color_swap_selected_empty_cells_keeps_each_format() {
let (mut s, sid) = setup("hello");
let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap();
let tid = result["table"].as_u64().unwrap();
s.edit(|d, _| {
let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap());
for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] {
let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over;
attrs.fill = Some(fill.into());
attrs.stroke = Some(stroke.into());
}
Ok(())
})
.unwrap();
s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid];
assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]"));
assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_failed_promotion_preserves_pending_and_history() {
let (mut s, _) = setup("hello");
s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
let before = s.doc().unwrap().clone();
let pending = attrs(&mut s);
assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err());
assert_eq!(attrs(&mut s), pending);
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
}
#[test]
fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() {
let (mut s, _) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
s.execute("edit.redo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
}
#[test]
fn integration_color_swap_validates_new_fill_before_any_edit() {
let (mut s, sid) = setup("hello");
s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string();
assert!(error.contains("Missing Ink"), "{error}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
assert_eq!(s.journal, journal);
}

View File

@ -1,396 +0,0 @@
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
use crate::{Result, Session};
/// Pages Shift-click autoflow may add before it gives up.
const AUTOFLOW_MAX_PAGES: usize = 2000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.thread",
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
cmd!(
"text.unthread",
"Break Thread",
[],
None,
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
has_doc,
unthread
),
cmd!(
"text.removeFromThread",
"Remove Frame from Thread",
[],
None,
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
has_doc,
remove_from_thread
),
]
}
/// `frame` param, or the first selected text frame.
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let st = s.doc()?;
let id = id_param(p, "frame")
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
return Err(bad(cmd, format!("{id} is not a text frame")));
}
check_editable(&st.doc, id, cmd)?;
Ok(id)
}
fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(cmd, format!("{id} is locked or on a locked layer")));
}
Ok(())
}
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err(bad("text.thread", "rect must be finite"));
}
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
let sr = spread_param(p, "spread")?;
let autoflow = bool_or(p, "autoflow", false);
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
let mut new_frame = || -> Result<ItemId> {
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
return Err(bad("text.thread", "the active layer is locked or hidden"));
}
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
};
let (from, to, joined) = match (from, to) {
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
(Some(f), None) => {
let t = new_frame()?;
(f, t, t)
}
(None, Some(t)) => {
let f = new_frame()?;
(f, t, f)
}
_ => return Err(bad("text.thread", "give two of from, to and rect")),
};
d.check_thread(from, to)?;
d.thread_keeping_story(from, to)?;
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 };
*sel = Selection::items(vec![joined]);
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
})
}
fn overset(d: &Document, sid: StoryId) -> bool {
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
}
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.unthread";
let frame = frame_param(s, p, CMD)?;
let d0 = &s.doc()?.doc;
let at = match str_param(p, "side").unwrap_or("after") {
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
};
check_editable(d0, at, CMD)?;
if let Some(next) = d0.next_frame(at) {
check_editable(d0, next, CMD)?;
}
s.edit(|d, _| {
d.unthread_after(at)?;
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
})
}
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
let frame = frame_param(s, p, "text.removeFromThread")?;
let d = &s.doc()?.doc;
for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() {
check_editable(d, neighbour, "text.removeFromThread")?;
}
s.edit(|d, _| {
d.remove_from_thread(frame)?;
Ok(json!({}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
s.execute("story.get", &json!({"frame": frame})).unwrap()
}
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
#[test]
fn thread_into_an_empty_frame_and_undo() {
let mut s = session();
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sid));
assert_eq!(frames(&mut s, a), [a, b]);
let d = &s.doc().unwrap().doc;
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
// The text now reaches the second frame.
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
}
#[test]
fn thread_into_a_text_frame_merges_its_story() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
let st = story(&mut s, b);
assert_eq!(st["id"].as_u64(), Some(sa));
assert_eq!(st["text"], "First\nSecond");
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn thread_into_the_middle_of_a_thread() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn bad_threads_are_errors_and_change_nothing() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
let group = {
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.doc().unwrap().selection.items[0].0
};
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
for p in [
json!({"from": a, "to": a}),
json!({"from": a, "to": g}),
json!({"from": g, "to": a}),
json!({"from": a, "to": group}),
// Earlier in the same thread.
json!({"from": b, "to": a}),
// Text already flows into y from x.
json!({"from": a, "to": y}),
json!({"from": a, "to": 999_999}),
json!({"from": a}),
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
json!({"rect": [0, 0, 10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
json!({"from": "a", "to": [1]}),
json!([1, 2]),
] {
assert!(s.execute("text.thread", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
}
#[test]
fn locked_frames_and_layers_refuse_threads() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
let layer = s.doc().unwrap().active_layer.0;
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
s.execute("edit.undo", &json!({})).unwrap();
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
let mut p = json!({"id": layer});
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
s.execute("layer.set", &p).unwrap();
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(frames(&mut s, a), [a]);
}
#[test]
fn thread_a_new_frame_after_or_in_front() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
let n = r["to"].as_u64().unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
// In front of the first frame: the story keeps its id and text.
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
let m = r["from"].as_u64().unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(frames(&mut s, a), [m, a, n]);
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
s.doc().unwrap().doc.check().unwrap();
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
}
use designcraft_geom::Rect;
#[test]
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false);
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
assert!(frames(&mut s, a).len() >= 3);
s.doc().unwrap().doc.check().unwrap();
// One undo removes the frames and the pages.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn autoflow_from_a_parent_frame_is_an_error() {
let mut s = session();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute(
"frame.create",
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
);
let a = r.unwrap()["id"].as_u64().unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
assert!(e.to_string().contains("text.thread"), "{e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn unthread_after_and_before() {
let mut s = session();
let text = LONG.repeat(12);
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
// Break after the first frame: b and c stay threaded to each other, empty.
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(r["overset"], true);
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(frames(&mut s, b), [b, c]);
assert_eq!(story(&mut s, b)["text"], "");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
// Break before the last frame, with the frame selected.
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
assert_eq!(frames(&mut s, a), [a, b]);
assert_eq!(frames(&mut s, c), [c]);
// Nothing to break.
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
}
#[test]
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
let mut s = session();
let text = LONG.repeat(12);
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(story(&mut s, b)["text"], "");
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
s.doc().unwrap().doc.check().unwrap();
}
}

View File

@ -129,63 +129,6 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
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<String> = (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();
@ -215,7 +158,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(page));
let cs = s.cache.get(&d, sid, Some(&d.page_name(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));

View File

@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, text.as_bytes())?;
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),

View File

@ -7,8 +7,6 @@
#![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;
@ -93,9 +91,6 @@ 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<Arc<Document>>,
/// 1-based record currently previewed.
@ -134,7 +129,6 @@ 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,
@ -183,8 +177,6 @@ pub struct Prefs {
pub rich_black_output: bool,
/// Font menu favourites (family names).
pub favorite_fonts: Vec<String>,
/// Fonts picked in the font menus, most recent first (shown at the menus' top).
pub recent_fonts: Vec<String>,
/// 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,
@ -241,7 +233,6 @@ 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,
@ -276,8 +267,6 @@ pub struct Session {
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
@ -309,7 +298,6 @@ impl Session {
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
library: None,
conveyor: Vec::new(),
book: None,
@ -386,9 +374,8 @@ impl Session {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
}
if i < self.docs.len() {
self.docs.remove(i);
@ -478,7 +465,7 @@ impl Session {
}
let mut d = (*st.doc).clone();
if overset {
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow");
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
} else {
// Drop empty trailing frames on the last pages (one frame, nothing else there).
while let Some(story) = d.story(sid) {

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,7 @@
//!
//! **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. A leading byte order mark and CRLF line endings are
//! accepted.
//! lines starting with `#` or `//` are ignored.
use serde_json::{Value, json};
@ -23,9 +22,7 @@ pub struct Step {
/// Parse script text (see the module docs).
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
// 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();
let t = text.trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@ -177,17 +174,6 @@ 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"})];

View File

@ -284,15 +284,7 @@ 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();
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);
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
}
#[test]
@ -380,30 +372,6 @@ fn step_and_repeat_grid() {
assert_eq!(last.y0, 36.0 + 2.0 * 40.0);
}
/// rows × columns once overflowed: a panic in debug builds, and in release builds a product that
/// wrapped to 0 passed the 1000-copy limit and started a grid of 2⁶⁴ copies.
#[test]
fn step_and_repeat_with_a_huge_grid_makes_no_grid() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
let before = items(&s);
// Counts past the limit are an error, and nothing is copied.
assert!(s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).is_err());
assert_eq!(items(&s), before);
}
/// The column count is the caller's and sizes a list every time the page's columns are laid out.
#[test]
fn new_document_caps_the_column_count() {
let mut s = Session::new();
s.execute("file.new", &json!({"columns": 4_000_000_000_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
// Past u32 the count wrapped (2³² + 3 became 3).
s.execute("file.new", &json!({"columns": 4_294_967_299_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
}
#[test]
fn snippets_roundtrip_between_documents() {
let mut s = Session::new();
@ -569,103 +537,3 @@ fn commands_take_non_object_params_without_panicking() {
assert!(r.is_ok(), "{p}: {r:?}");
}
}
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
/// a new frame is threaded there and the overset text flows into it.
#[test]
fn threading_with_the_loaded_text_cursor_on_the_sample() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story");
// Break the thread so the first frame is overset, and select it.
s.execute("text.unthread", &json!({"frame": first.0})).unwrap();
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset());
s.execute("selection.set", &json!({"ids": [first.0]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let layout = s.layout();
let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap();
let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y);
s.pointer(&ev(PointerKind::Down, port), v).unwrap();
s.pointer(&ev(PointerKind::Up, port), v).unwrap();
assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText);
// On the pasteboard left of the first spread.
let b = layout.slots[0].bounds;
let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0);
let undo = s.doc().unwrap().history.undo.len();
s.pointer(&ev(PointerKind::Down, at), v).unwrap();
s.pointer(&ev(PointerKind::Up, at), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
let frames = st.doc.story(sid).unwrap().frames.clone();
assert_eq!(frames.len(), 2);
let new = frames[1];
assert_eq!(st.selection.items, [new]);
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame");
let nb = s.doc().unwrap().doc.item(new).unwrap().bounds();
assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}");
}
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
use designcraft_tools::{PointerEvent, PointerKind};
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
}
#[test]
fn text_frame_resize_snaps_to_the_margins() {
use designcraft_geom::{Point, Vec2};
for zoom in [1.0, 0.5] {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(zoom);
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
// Right handle to 1.5 pt short of the right margin.
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
// Bottom handle to 1.5 pt past the bottom margin.
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
// Top-left corner handle onto the top and left margins.
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
}
}
#[test]
fn type_tool_frames_snap_to_the_margins() {
use designcraft_geom::Point;
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
s.set_tool("type");
let v = ViewInfo::at_zoom(1.0);
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
let before = s.doc().unwrap().doc.spreads[0].items.len();
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let items = &s.doc().unwrap().doc.spreads[0].items;
assert_eq!(items.len(), before + 1, "a frame was drawn");
let r = items.last().unwrap().bounds();
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}

View File

@ -91,91 +91,6 @@ fn magazine_round_trips_through_idml() {
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
}
/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt),
/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`.
fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
use std::io::Write;
let paras: String = lines
.iter()
.enumerate()
.map(|(p, &n)| {
let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}");
format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#,
if p + 1 < lines.len() { "<Br/>" } else { "" }
)
})
.collect();
let frame = |me: &str, x: f64, prev: &str, next: &str| {
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/>
<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup>
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle>
</RootParagraphStyleGroup>
<Story Self="s1">{paras}</Story>
<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread>
</Document>"#,
frame("t1", 36.0, "n", "t2"),
frame("t2", 234.0, "t1", "n")
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("mimetype", stored).unwrap();
w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap();
w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(designmap.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
/// Lines of paragraph `para` in each frame of the story.
fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> {
let sid = *d.stories.keys().next().unwrap();
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect()
}
/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph.
/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4.
#[test]
fn idml_keep_lines_without_mode_split_at_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 0), [13, 0]);
assert_eq!(para_lines_per_frame(&d, 1), [6, 4]);
// All lines in paragraph, written explicitly: the paragraph moves whole.
let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 1), [0, 10]);
}
/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines
/// together after loading; saved now, it keeps lines at start/end of paragraph.
#[test]
fn format_1_keep_lines_without_mode_lays_out_all_lines() {
use std::io::Write;
let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap();
assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]);
let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap();
assert_eq!(para_lines_per_frame(&now, 1), [6, 4]);
}
#[test]
fn idml_commands() {
let mut s = Session::new();
@ -336,30 +251,3 @@ fn named_lists_round_trip_through_idml() {
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}
#[test]
fn type_para_sets_a_drop_cap_that_survives_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
let lines = &cs.frames[0].lines;
let dc = &lines[0].glyphs[0];
assert_eq!(dc.byte, 0);
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false)));
// The drop cap's character style goes through IDML as a leading Dropcap nested style.
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}

View File

@ -176,348 +176,3 @@ fn file_roundtrip_keeps_tables() {
let st = back.story(designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
}
fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table {
s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap()
}
#[test]
fn explicit_cell_edges_win_imported_priorities_and_keep_undo_and_file_roundtrip() {
for (direction, neighbors) in [
(doc::TextDirection::LeftToRight, [(0, 1, 2), (1, 0, 3), (1, 2, 1), (2, 1, 0)]),
(doc::TextDirection::RightToLeft, [(0, 1, 2), (1, 0, 1), (1, 2, 3), (2, 1, 0)]),
] {
for imported_priority in [-7, 41, i32::MAX] {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 3, "cols": 3})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.border = doc::CellStroke::none();
t.options.direction = direction;
for cell in &mut t.cells {
cell.strokes = std::array::from_fn(|_| doc::CellStroke::none());
}
t.cell_mut(0, 0).unwrap().stroke_priorities[0] = -9;
for (row, col, edge) in neighbors {
let cell = t.cell_mut(row, col).unwrap();
cell.strokes[edge] = doc::CellStroke { weight: 7.0, ..Default::default() };
cell.stroke_defined[edge] = true;
cell.stroke_priorities[edge] = imported_priority;
}
Ok(())
})
.unwrap();
let before = table_for(&s, sid, tid).clone();
s.execute("table.setCell", &json!({"row": 1, "col": 1, "stroke": {"weight": 0, "edges": "all"}})).unwrap();
let after = table_for(&s, sid, tid).clone();
assert_eq!(after.cell(0, 0).unwrap().stroke_priorities[0], -9, "unrelated signed priorities are preserved");
let edited = after.cell(1, 1).unwrap();
let priority = if imported_priority == i32::MAX { 2 } else { imported_priority.max(0) + 1 };
assert_eq!(edited.stroke_defined, [true; 4]);
assert_eq!(edited.border_overrides, [true; 4]);
assert_eq!(edited.stroke_priorities, [priority; 4]);
for (row, col, edge) in neighbors {
let neighbor = after.cell(row, col).unwrap();
assert_eq!(neighbor.strokes[edge].weight, 0.0);
assert!(neighbor.stroke_defined[edge]);
assert!(neighbor.border_overrides[edge]);
assert_eq!(neighbor.stroke_priorities[edge], priority);
}
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
assert!(composed.frames.iter().flat_map(|f| &f.tables).all(|t| t.strokes.is_empty()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &after);
let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc);
let back = crate::cmd::file_from(&bytes).unwrap();
assert_eq!(back.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after);
check(&s);
}
}
}
#[test]
fn perimeter_edits_override_imported_sides_and_uniform_border_clears_side_options() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.border.weight = 4.0;
t.options.borders = std::array::from_fn(|_| Some(doc::CellStroke { weight: 9.0, ..Default::default() }));
Ok(())
})
.unwrap();
s.execute("table.setCell", &json!({"stroke": {"tint": 0.25, "edges": "left"}})).unwrap();
let t = table_for(&s, sid, tid);
let cell = t.cell(0, 0).unwrap();
assert_eq!(cell.stroke_defined, [false, true, false, false]);
assert_eq!(cell.border_overrides, [false, true, false, false]);
assert_eq!(cell.strokes[1].weight, 9.0, "partial edits preserve the visible perimeter base");
assert_eq!(cell.strokes[1].tint, 0.25);
let before = t.clone();
s.execute("table.options", &json!({"border": {"weight": 3}})).unwrap();
let t = table_for(&s, sid, tid);
assert!(t.options.borders.iter().all(Option::is_none));
assert_eq!(t.options.border.weight, 3.0);
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap();
assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 3.0).count(), 3);
assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 9.0 && s.stroke.tint == 0.25).count(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
}
#[test]
fn inherited_cell_and_table_styles_apply_and_follow_base_edits() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("style.cell.create", &json!({"name": "Cell Base", "insets": 7, "stroke": {"weight": 2}})).unwrap();
s.execute("style.cell.create", &json!({"name": "Cell Child", "basedOn": "Cell Base", "fill": "[Paper]"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Cell Child", "table": tid})).unwrap();
let t = table_for(&s, sid, tid);
assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [7.0; 4] && cell.strokes[0].weight == 2.0));
s.execute("style.cell.edit", &json!({"name": "Cell Base", "insets": 5})).unwrap();
assert!(table_for(&s, sid, tid).cells.iter().all(|cell| cell.insets == [5.0; 4]));
s.execute("style.table.create", &json!({"name": "Table Base", "body": "Cell Child", "border": {"weight": 6}, "spaceBefore": 8})).unwrap();
s.execute("style.table.create", &json!({"name": "Table Child", "basedOn": "Table Base", "spaceAfter": 3})).unwrap();
s.execute("style.table.apply", &json!({"name": "Table Child"})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.style, "Table Child");
assert_eq!(t.options.border_for(0).weight, 6.0);
assert_eq!(t.options.space_before, 8.0);
assert_eq!(t.options.space_after, 3.0);
assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [5.0; 4]));
s.execute("style.table.edit", &json!({"name": "Table Base", "border": {"weight": 4}, "spaceBefore": 10})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.options.border_for(0).weight, 4.0);
assert_eq!(t.options.space_before, 10.0);
assert_eq!(t.options.space_after, 3.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid).options.border_for(0).weight, 6.0);
}
#[test]
fn uniform_style_edits_replace_imported_per_side_attributes() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
d.styles_mut().cell.push(doc::CellStyle {
name: "Imported Cell".into(),
strokes: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(9.0), ..Default::default() }),
inset_overrides: [Some(12.0); 4],
..Default::default()
});
d.styles_mut().table.push(doc::TableStyle {
name: "Imported Table".into(),
borders: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(10.0), ..Default::default() }),
..Default::default()
});
Ok(())
})
.unwrap();
s.execute("style.cell.apply", &json!({"name": "Imported Cell"})).unwrap();
s.execute("style.cell.edit", &json!({"name": "Imported Cell", "stroke": {"weight": 2}, "insets": 3})).unwrap();
let cell = table_for(&s, sid, tid).cell(0, 0).unwrap();
assert!(cell.strokes.iter().all(|edge| edge.weight == 2.0));
assert_eq!(cell.insets, [3.0; 4]);
s.execute("style.table.apply", &json!({"name": "Imported Table"})).unwrap();
s.execute("style.table.edit", &json!({"name": "Imported Table", "border": {"weight": 4}})).unwrap();
let t = table_for(&s, sid, tid);
assert!((0..4).all(|edge| t.options.border_for(edge).weight == 4.0));
}
#[test]
fn rtl_cell_edge_selection_uses_physical_sides() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("table.options", &json!({"direction": "rightToLeft", "border": {"weight": 0}})).unwrap();
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0}})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "stroke": {"weight": 2, "edges": "left"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 2.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[1].weight, 0.0);
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 5, "edges": "outer"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[3].weight, 0.0);
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0, "edges": "inner"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 0.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 0.0);
}
#[test]
fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() {
for direction in [doc::TextDirection::LeftToRight, doc::TextDirection::RightToLeft] {
for imported_priority in [41, i32::MAX] {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("table.merge", &json!({"row": 0, "cols": [0, 1]})).unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.direction = direction;
t.options.border = doc::CellStroke::none();
for cell in &mut t.cells {
cell.strokes = std::array::from_fn(|_| doc::CellStroke::none());
}
let merged = t.cell_mut(0, 0).unwrap();
merged.strokes[2] = doc::CellStroke { weight: 7.0, ..Default::default() };
merged.stroke_defined[2] = true;
merged.stroke_priorities[2] = imported_priority;
t.cell_mut(1, 1).unwrap().stroke_priorities[2] = 7;
Ok(())
})
.unwrap();
let before = table_for(&s, sid, tid).clone();
s.execute("table.setCell", &json!({"row": 1, "col": 0, "stroke": {"weight": 0, "edges": "top"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[2].weight, 7.0, "the merged edge itself must not be erased");
let priorities =
[t.cell(1, 1).unwrap().stroke_priorities[2], t.cell(0, 0).unwrap().stroke_priorities[2], t.cell(1, 0).unwrap().stroke_priorities[0]];
assert_eq!(priorities, if imported_priority == i32::MAX { [1, 2, 3] } else { [7, 41, 42] });
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap();
let unselected = frag.cell(1, 1).unwrap().rect;
let visible: Vec<_> = frag.strokes.iter().filter(|s| s.stroke.weight == 7.0).collect();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].a, designcraft_geom::Point::new(unselected.x0, unselected.y0));
assert_eq!(visible[0].b, designcraft_geom::Point::new(unselected.x1, unselected.y0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
}
}
}
#[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", &params).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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More