Merge upstream main into feat/layout-parity

Conflict: ROADMAP.md: took main's restructured roadmap; this branch's inventory changes (Keep Lines Together default, IDML defaults, drop caps, IDML Kashidas) go into docs/target-app-parity.md, where the inventory lives now
Conflict: crates/format/src/lib.rs: load runs the format upgrade, then merges duplicate assets; kept both sets of tests
This commit is contained in:
aiongg
2026-10-12 03:24:27 +00:00
72 changed files with 4878 additions and 1140 deletions
+4 -35
View File
@@ -23,38 +23,7 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL -o /tmp/rustup-init \
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
chmod 700 /tmp/rustup-init
/tmp/rustup-init -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+2 -51
View File
@@ -19,54 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c -
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/designcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<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
# 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
+17 -419
View File
@@ -1,12 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "DesignCraft v<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:
@@ -20,420 +11,27 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
DESIGNCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets needed, and branch dry runs need this job.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### DesignCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-22.04
deb: amd64
nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97
- arch: aarch64
runner: ubuntu-22.04-arm
deb: arm64
nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
NFPM_SHA256: ${{ matrix.nfpm_sha256 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c -
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/designcraft$|metainfo|applications'
ldd target/release/designcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.designcraft.yml')); assert m['id'] == 'ai.storyteller.designcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "DESIGNCRAFT_VERSION DESIGNCRAFT_BUILD_DATE DESIGNCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL -o /tmp/rustup-init \
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
chmod 700 /tmp/rustup-init
/tmp/rustup-init -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/designcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$DESIGNCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
archive="$RUNNER_TEMP/trunk.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c -
tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="DesignCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: designcraft
app-title: DesignCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+6 -66
View File
@@ -26,70 +26,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: designcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: designcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'DesignCraft'
foreach ($exe in 'designcraft.exe', 'designcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'designcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "designcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok designcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'designcraft.exe')) { throw 'uninstall left designcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: designcraft
app-dir: DesignCraft
+1 -1
View File
@@ -38,7 +38,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
## Contributor credits (About window)
+1 -1
View File
@@ -38,4 +38,4 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
Generated
+47 -27
View File
@@ -796,6 +796,23 @@ dependencies = [
"libm",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -870,7 +887,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-engine",
"designcraft-ui-egui",
@@ -887,7 +904,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -903,7 +920,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"kurbo",
"moxcms",
@@ -915,7 +932,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -933,7 +950,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -945,9 +962,10 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"caseless",
"craft-print",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -978,7 +996,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -987,7 +1005,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -999,7 +1017,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-doc",
"serde",
@@ -1010,7 +1028,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1023,7 +1041,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1036,7 +1054,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@@ -1047,7 +1065,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1060,7 +1078,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1082,7 +1100,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1100,7 +1118,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@@ -1109,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1121,8 +1139,9 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"craft-ui",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -1142,11 +1161,12 @@ dependencies = [
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
"zip",
]
[[package]]
name = "designcraft-web"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -1463,7 +1483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2637,7 +2657,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2720,7 +2740,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3829,7 +3849,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4244,7 +4264,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4479,7 +4499,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5216,7 +5236,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5666,7 +5686,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"serde_json",
]
+5 -2
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -67,6 +68,8 @@ quick-xml = "0.38"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
+20
View File
@@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md.
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"):
hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence:
Redistribution and use in source and binary forms, with or without modification, are permitted
provided that the following conditions are met: * Redistributions of source code must retain the
above copyright notice, this list of conditions and the following disclaimer. * Redistributions in
binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution. * Neither
the name of the University of Campinas, of the University of Minho nor the names of its
contributors may be used to endorse or promote products derived from this software without
specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
releases) embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
+5 -1
View File
@@ -98,6 +98,8 @@ 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
@@ -183,7 +185,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
| L6 | `ui-egui` (InDesign-style UI, control channel) |
| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` |
- **Status and milestones:** [ROADMAP.md](ROADMAP.md)
- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md)
- **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md)
- **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md)
- **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md)
@@ -221,6 +223,8 @@ 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 |
+170 -222
View File
@@ -1,229 +1,177 @@
# DesignCraft roadmap
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- 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-11 · **Change:** minor (shared print spooler; Windows CI passes) · **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 remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
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)
**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 — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — 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.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- 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`.
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step.
- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize.
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text.
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<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; IDML Kashidas): 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, Italian, 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. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- 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, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), 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; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); 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 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 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 |
| | Value | Kind |
|---|---|---|
| M0 | Skeleton + vertical slice | ✅ |
| M1 | Selection, transform, layers, pages, MCP | in progress |
| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | in progress |
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started |
| M4 | Color & effects | started |
| M5 | Graphics & links | started |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) |
| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) |
| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) |
| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) |
| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) |
| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) |
| M13 | Automation (scripts, batch) | |
| M14 | 1.0 polish & packaging | |
| **Feature breadth** | **~80%** (69% of InDesign 21.6's in-scope menu items matched; 99% of our 275-row checklist) | measured bounds, estimate between |
| **Ready for real work** | **~47%** (range 42–52%) | estimated: weighted sum over the dimensions below |
| Mainstream practitioner | **~42%** (weekly core 60.7% × 0.88 interaction × 0.92 stability × 0.85 file exchange) | estimated |
| Essentials user | **~54%** (core features 72% × 0.90 launch × 0.88 discoverability × 0.95 files received) | estimated |
| Remaining to **beta** | **420–750 Opus 5.5 agent-hours** (~120–200 wall-clock hours with 4–5 agents) | estimated |
| Remaining to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated |
| Audience | Ready | Opus 5.5 agent-hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~47% | 1,150–1,950 (~70% parallel) | depth across all areas, scripting compatibility, localization, typography fidelity, INDD/IDML |
| Mainstream practitioner | ~42% | 500–850 (~65% parallel) | INDD import and IDML corpus, composition fidelity, weekly-area depth, PDF/X-1a and printing |
| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing |
**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in
[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to
another: the in and out ports are drawn, but clicking the out port does nothing and there is no
thread or unthread command. Long text flows only through autoflow on Place and Primary Text
Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything
else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF
export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual
threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on
real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux.
Details: [docs/target-app-parity.md](docs/target-app-parity.md).
## By dimension
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 55% | 250–400 | [target-app-parity.md](docs/target-app-parity.md) |
| Typography and composition fidelity | 45% | 120–200 | [typography-parity.md](docs/typography-parity.md) |
| File formats | 40% | 150–250 + 20–60 (INDD) | [file-format-parity.md](docs/file-format-parity.md) |
| UI/UX fidelity | 45% | 120–180 | [ui-parity.md](docs/ui-parity.md) |
| Stability | 45% | 60–100 | [gaps.md](docs/gaps.md) |
| Performance | 65% | 40–80 | [gaps.md](docs/gaps.md) |
| Localization | 45% | 120–180 | [localization-parity.md](docs/localization-parity.md) |
| Platforms | 55% | 40–80 | [hardware-parity.md](docs/hardware-parity.md) |
| Hardware | 45% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Ecosystem (scripting, plug-ins) | 15% | 150–250 | [gaps.md](docs/gaps.md) |
| AI features | 15% | 60–120 | [gaps.md](docs/gaps.md) |
All "Ready" figures are estimates; the weights and evidence are in target-app-parity.md.
## Features
Breadth from [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity`,
measured); Ready estimated.
| Area | Breadth | Ready | Hours |
|---|---|---|---|
| Application shell & workspace | 99% | 55% | 40–60 |
| Documents, pages, spreads | 100% | 65% | 20–35 |
| Layers | 100% | 75% | 5–10 |
| Frames, shapes & paths | 100% | 60% | 25–40 |
| Transform | 100% | 70% | 10–20 |
| Fill, stroke, colour | 100% | 60% | 20–35 |
| Effects & transparency | 100% | 50% | 20–30 |
| Placing & links | 93% | 55% | 20–35 |
| Type & text frames | 99% | 45% | 40–60 |
| Typography | 100% | 45% | see typography-parity.md |
| Styles | 100% | 60% | 15–25 |
| Tables | 100% | 55% | 25–40 |
| Long documents | 100% | 45% | 30–50 |
| Interactivity & digital | 100% | 40% | 30–50 |
| Output & production | 92% | 55% | 40–70 |
| XML & automation | 100% | 35% | see Ecosystem |
| View & navigation | 100% | 65% | 10–20 |
| Undo, history, saving | 100% | 70% | 5–10 |
| Accessibility | 100% | 50% | 15–25 |
## Languages
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
| Language | Code | Status | Translated |
|---|---|---|---|
| English | en | full | 100% |
| Simplified Chinese | zh | partial (no canvas IME) | 83% |
| Spanish | es | partial | 83% |
| Hindi | hi | none | 0% |
| Arabic | ar | partial (RTL UI and composition) | 85% |
| French | fr | partial | 83% |
| Portuguese (Brazil) | pt-br | partial | 61% |
| Indonesian | id | none | 0% |
| Japanese | ja | partial (no canvas IME) | 95% |
| German | de | partial | 83% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Italian (85%), Ukrainian (83%).
## Upcoming
Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in
[docs/gaps.md](docs/gaps.md).
1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h)
2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h)
3. CI running `cargo xtask ci` on pull requests (4–8 h)
4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h)
5. IDML real-file corpus and fixes (60–100 h)
6. Composition oracle against InDesign; optical kerning; overset rule (80–140 h)
7. Start on any GPU; find every installed font (25–50 h)
## Documents
[target-app-parity.md](docs/target-app-parity.md) (assessment) · [gaps.md](docs/gaps.md) (work
list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.md](docs/parity-checklist.md)
(presence checklist) · [typography-parity.md](docs/typography-parity.md) ·
[file-format-parity.md](docs/file-format-parity.md) · [ui-parity.md](docs/ui-parity.md) ·
[hardware-parity.md](docs/hardware-parity.md) · [localization-parity.md](docs/localization-parity.md) ·
[architecture.md](docs/architecture.md) · agents: [agents.md](docs/agents.md), [mcp.md](docs/mcp.md),
[control-protocol.md](docs/control-protocol.md) · CJK: [cjk.md](docs/cjk.md),
[cjk-typography.md](docs/cjk-typography.md) · Arabic: [arabic-typography.md](docs/arabic-typography.md)
## Progress log
Newest first.
- **2026-10-12** · 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 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
smoke-tested under QEMU).
- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was
42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%.
- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text
can't be threaded by hand (no out-port click, no thread command).
- **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and
localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of
2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules
standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap,
architecture, typography, file-format, UI, hardware and localization docs). Landed today:
split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules
dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish
hyphenation (#222), Japanese (#272), Italian (#218) and Ukrainian (#236) interfaces, relocated packages relink (#244), graphics backend chosen
before the window (#297), IDML table styles (#288), warichu (#280), list numbering from IDML
(#298), security hardening (#153, #155), many text-composition fixes (#282–#293).
- **2026-10-09** · Version 0.5.0; many user reports triaged into issues (#160–#265).
- **2026-10-08** · Version 0.4.0.
- **2026-10-07** · Community PRs: data merge, Brazilian Portuguese, PDF/X-4 flags, Office fonts
on macOS; CJK 1.x (Korean line breaking, fallback fonts in Package); PrintCraft renamed PdfCraft.
- **2026-10-06** · Snapping to parent items, Windows packaging, ARM64 workflow, control-channel
hardening.
- **2026-10-05** · FreeBSD build; caret-boundary fixes.
- **2026-10-04** · Never-crash pass across importers and the engine; rotate spread view; kashida
in styles; previous estimate: breadth 99%, "~83% including depth", ~170 h remaining.
- **2026-10-03** · Release 0.1.1; separations, XML tagging, track changes, PDF/X-4 output
intent, split window, buttons, menu customization.
- **2026-10-02** · Hyperlinks/Bookmarks panels, conditional text, tagged PDF, lists across
stories, style groups, Glyphs panel, High Contrast theme.
- **2026-10-01** · First commit; skeleton, IDML, PDF, tables, footnotes, cross-references, index,
spelling and parallel composition on the first day.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md |
| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) |
| 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h |
| 2026-10-01 | major | First roadmap: status, milestones |
+1
View File
@@ -3,6 +3,7 @@ name = "designcraft-web"
description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
+1
View File
@@ -2,6 +2,7 @@
name = "designcraft"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
default-run = "designcraft"
+3 -1
View File
@@ -367,7 +367,9 @@ mod tests {
// out of the set once it failed.
write(&path, &record(Some(Backend::Gl), &[])).expect("write");
let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates");
if candidates().len() > 1 {
// This synthetic prior start records OpenGL even on Metal-only macOS.
// Switching depends on the chosen backend, not the candidate count.
if s.backend != Backend::Gl {
assert_eq!(s.switched_from, Some(Backend::Gl));
let line = s.status_line().expect("status");
assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}");
+6 -19
View File
@@ -16,6 +16,7 @@ mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod profile;
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Services};
@@ -67,27 +68,13 @@ impl eframe::App for App {
}
}
/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
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"))
}
}
/// `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> {
config_dir().map(|b| b.join("ui.json"))
profile::config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut DesignApp) {
@@ -96,7 +83,7 @@ fn load_prefs(app: &mut DesignApp) {
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<designcraft_ui_egui::UiState>(&bytes)
&& let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes)
{
app.ui = ui;
}
@@ -249,7 +236,7 @@ fn main() -> eframe::Result {
}
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, config_dir()) {
if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
@@ -273,7 +260,7 @@ fn main() -> eframe::Result {
}
// The graphics backend, decided before the window exists: a driver that faults takes the
// process down before any Rust code can catch it (see `gpu`).
let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
if let Some(s) = &gpu
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
@@ -304,7 +291,7 @@ fn main() -> eframe::Result {
}
let mut session = Session::new();
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
session.recovery_dir = designcraft_engine::recovery::default_dir();
session.recovery_dir = profile::recovery_dir();
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
let mut app = DesignApp::new(session, services());
if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) {
+71
View File
@@ -0,0 +1,71 @@
//! 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)
}
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()));
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ designcraft-fonts = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
miniz_oxide = "0.8"
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] }
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+27 -5
View File
@@ -1,6 +1,7 @@
//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the
//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE).
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE).
//!
//! English lookup order for each word:
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
@@ -232,9 +233,8 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
if letters < lim.min_word.max(2) {
return false;
}
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
return false;
}
// Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate
// Capitalized Words off keeps both, acronyms included, whole.
if !lim.capitalized && run[0].is_uppercase() {
return false;
}
@@ -332,7 +332,10 @@ mod tests {
#[test]
fn case_apostrophes_and_punctuation() {
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY");
assert_eq!(h("TYPOGRAPHY"), "TY-POG-RA-PHY");
assert_eq!(h("COMMODI"), "COM-MO-DI");
let whole = Limits { capitalized: false, ..Limits::default() };
assert_eq!(hyphenate_word("TYPOGRAPHY", &whole), "TYPOGRAPHY", "Hyphenate Capitalized Words off");
assert_eq!(h("typography,"), "ty-pog-ra-phy,");
assert_eq!(h("“typography”"), "“ty-pog-ra-phy”");
assert_eq!(h("typography's"), "ty-pog-ra-phy's");
@@ -389,6 +392,25 @@ mod tests {
assert!(Lang::for_language("French").is_none(), "only languages built in");
}
#[test]
fn portuguese_uses_the_hyph_pt_patterns() {
let pt = Lang::for_language("Portuguese: Brazilian").unwrap();
assert_eq!(pt, Lang::for_language("Portuguese").unwrap());
assert_eq!(pt, Lang::for_language("pt_BR").unwrap());
assert_ne!(pt, Lang::for_language("Spanish").unwrap());
// hyph-pt's own minimum: three letters after the last break.
let lim = Limits { before_last: 3, ..Limits::default() };
let h = |w: &str| {
let pts = hyphen_points_in(w, &lim, pt);
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<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();
+17
View File
@@ -2425,6 +2425,23 @@ fn spanish_text_gets_spanish_hyphenation() {
}
}
#[test]
fn portuguese_text_gets_portuguese_hyphenation() {
let text = "matemática matemática matemática matemática matemática matemática";
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
let n = st.len();
st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect();
assert!(!breaks.is_empty(), "Portuguese text is hyphenated");
// Portuguese syllables: ma-te-má-tica.
for b in breaks {
let tail = b.rsplit(' ').next().unwrap_or(b);
assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}");
}
}
#[test]
fn cjk_aki_adds_space_without_scaling_outlines() {
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
+351
View File
@@ -0,0 +1,351 @@
//! Placed files (assets): one asset per distinct file, the assets a document uses, and the
//! compaction a save applies (duplicates merged, unused assets left out).
//!
//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page
//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`].
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use crate::story::Story;
use crate::{Asset, AssetId, Content, Document, Item};
/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper
/// is treated as using every asset, so nothing it references is merged away or pruned.
const MAX_DEPTH: usize = 64;
impl Asset {
/// Whether `other` holds the same file: the same type, page, link and bytes. Files at
/// different paths stay apart even with identical bytes, since each can be updated or
/// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only
/// when they link to the same path.
pub fn same_file(&self, other: &Asset) -> bool {
if self.mime != other.mime || self.page != other.page {
return false;
}
if self.link != other.link {
return false;
}
match (self.data.is_empty(), other.data.is_empty()) {
(false, false) => self.same_bytes(other),
(true, true) => self.link.is_some(),
_ => false,
}
}
fn same_bytes(&self, other: &Asset) -> bool {
Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data)
}
}
/// The asset in `assets` holding the same file as `asset`.
pub fn find_same_asset(assets: &BTreeMap<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)]);
}
}
+2
View File
@@ -12,6 +12,7 @@
pub mod anchored;
pub mod arabic;
pub mod arrow;
pub mod assets;
pub mod attrs;
pub mod build;
pub mod cjk;
@@ -36,6 +37,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK};
pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
pub use attrs::*;
pub use datamerge::{
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
+1
View File
@@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma
workspace = true
[dependencies]
craft-print = { workspace = true }
designcraft-images = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
+26 -15
View File
@@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
let new_id = ItemId(dst.alloc());
maps.items.insert(old, new_id);
it.id = new_id;
if let Some(m) = it.media.as_mut()
&& let Some(p) = m.poster
{
m.poster = dst.adopt_asset(src, p).or(Some(p));
}
match &mut it.content {
Content::Text(tf) => {
let new_sid = if let Some(n) = maps.stories.get(&tf.story) {
@@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
let n = StoryId(dst.alloc());
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
st.id = n;
let mut assets = HashMap::new();
for a in designcraft_doc::story_asset_refs(&st) {
if let Some(na) = dst.adopt_asset(src, a)
&& na != a
{
assets.insert(a, na);
}
}
designcraft_doc::remap_story_assets(&mut st, &assets);
st.frames.clear();
dst.stories.insert(n, Arc::new(st));
maps.stories.insert(tf.story, n);
@@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
}
}
Content::Graphic(g) => {
if let Some(a) = src.assets.get(&g.asset) {
dst.assets.entry(g.asset).or_insert_with(|| a.clone());
if let Some(a) = dst.adopt_asset(src, g.asset) {
g.asset = a;
}
}
Content::Group { items } => {
@@ -355,21 +369,18 @@ fn place_image(
};
let (nw, nh) = (f64::from(pw), f64::from(ph));
let mode = fitting_of(&options.fitting);
let aid = AssetId(doc.alloc());
let fresh = AssetId(doc.alloc());
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
doc.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name,
mime: designcraft_render::image_mime(&bytes).to_string(),
link,
data: Arc::new(bytes),
pixels: Some((pw, ph)),
page: 0,
}),
);
let aid = doc.add_asset(Asset {
id: fresh,
name,
mime: designcraft_render::image_mime(&bytes).to_string(),
link,
data: Arc::new(bytes),
pixels: Some((pw, ph)),
page: 0,
});
let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] };
if let Some(xf) = g.fitted(inner, mode) {
g.xf = xf;
+31 -15
View File
@@ -18,7 +18,7 @@ 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}",
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}",
always, file_open),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
@@ -313,8 +313,9 @@ 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.
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages
// and SVGs keep their exact size: rounding it to whole points would stretch them unevenly.
let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64));
// Image Import Options › Crop to: the frame shows that box of the PDF page.
let crop_box = match str_param(p, "pdfCrop") {
Some(k) if designcraft_render::is_pdf(&bytes) => {
@@ -336,9 +337,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let py = p.get("y").and_then(Value::as_f64);
let want_w = p.get("width").and_then(Value::as_f64);
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) });
// The width the whole page (or image) gets; the height follows the proportions.
let w = match want_w {
Some(w) => w,
@@ -350,14 +351,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 frame proportionally.
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
let k = (r.width() / nw).max(r.height() / nh);
let (gw, gh) = (nw * k, nh * k);
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
auto_fit: designcraft_doc::Fitting::FillProportionally,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
@@ -440,16 +441,13 @@ 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 aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }),
);
Ok(aid)
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
})?;
s.loaded = Some((aid, (pw as f64, ph as f64)));
s.loaded = Some((aid, natural));
s.set_tool("placeGun");
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
}
@@ -471,7 +469,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: designcraft_doc::Fitting::FillProportionally,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
@@ -689,6 +687,24 @@ mod pdf_crop_tests {
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
}
#[test]
fn placed_pdf_keeps_its_exact_page_size() {
// A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap();
let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let d = &t.doc().unwrap().doc;
let it = d.item(id).unwrap();
let b = it.bounds();
assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}");
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") };
assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size);
}
}
#[cfg(test)]
+12 -5
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}",
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}",
always, open_idml),
]
}
@@ -34,6 +34,11 @@ 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>> {
@@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
None
}
};
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
let designcraft_idml::Imported { document, warnings } =
designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let document = {
let mut document = document;
@@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
resolve_packaged_links(&mut document, dir);
document
};
Ok(document)
Ok((document, warnings))
}
#[cfg(not(target_arch = "wasm32"))]
@@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
let mut d = import(&bytes, dir.as_deref())?;
let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?;
if let Some(n) = name {
d.title = n;
}
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
Some(path) => super::file::load_document_fonts(&mut d, path),
None => (None, 0, Vec::new()),
};
warnings.extend(font_warnings);
// Never save over the .idml with the native format: the document starts unsaved.
let mut st = DocState::new(d, None);
st.fonts = fonts;
+117
View File
@@ -112,8 +112,10 @@ 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()
@@ -311,3 +313,118 @@ mod relink_folder_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod shared_asset_tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<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);
}
}
+10 -5
View File
@@ -13,11 +13,16 @@ use crate::{Result, Session};
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
let (svg, (w, h)) = crate::math::to_svg(latex, size);
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }),
);
let fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset {
page: 0,
id: fresh,
name: "math.svg".into(),
mime: "image/svg+xml".into(),
link: None,
data: Arc::new(svg),
pixels: None,
});
let id = ItemId(d.alloc());
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
+4 -5
View File
@@ -25,8 +25,8 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
let mime = mime.to_string();
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None }));
let fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None });
let id = match target {
Some(fid) => {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
@@ -90,10 +90,9 @@ pub fn specs() -> Vec<CommandSpec> {
s.edit(|d, _| {
let poster_id = match poster {
Some(Some((bytes, name, link, px))) => {
let aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }));
Some(Some(aid))
Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })))
}
Some(None) => Some(None),
None => None,
+18 -6
View File
@@ -900,6 +900,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
&& let Some(p) = m.poster
{
m.poster = dst.adopt_asset(src, p).or(Some(p));
}
match &mut it.content {
Content::Text(tf) => {
let new_sid = match stories.get(&tf.story) {
@@ -908,6 +913,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
let n = StoryId(dst.alloc());
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
st.id = n;
let mut assets = HashMap::new();
for a in designcraft_doc::story_asset_refs(&st) {
if let Some(na) = dst.adopt_asset(src, a)
&& na != a
{
assets.insert(a, na);
}
}
designcraft_doc::remap_story_assets(&mut st, &assets);
st.frames.clear();
dst.stories.insert(n, Arc::new(st));
stories.insert(tf.story, n);
@@ -920,10 +934,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
}
}
Content::Graphic(g) => {
if let Some(a) = src.assets.get(&g.asset)
&& !dst.assets.contains_key(&g.asset)
{
dst.assets.insert(g.asset, a.clone());
if let Some(a) = dst.adopt_asset(src, g.asset) {
g.asset = a;
}
}
Content::Group { items } => {
@@ -2463,8 +2475,8 @@ mod fitting_tests {
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
let c = content(&s);
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
// Crop 10 pt on every side: the content spreads past the frame.
s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap();
// Crop 10 pt on every side and fit again: the content spreads past the frame.
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
let c = content(&s);
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
// Auto-fit: resizing the frame refits the content.
+3 -3
View File
@@ -285,13 +285,13 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
let mut out = d.clone();
let mut taken = Vec::new();
let mut written = Vec::new();
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
ids.sort_by_key(|i| i.0);
// Only files something places; the saved document leaves the others out.
let ids = d.used_assets();
for id in ids {
if only.is_some_and(|o| !o.contains(&id)) {
continue;
}
let a = &d.assets[&id];
let Some(a) = d.assets.get(&id) else { continue };
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
let path = dir.join(&name);
root.write(&path, a.data.as_slice())?;
+65 -91
View File
@@ -1,7 +1,4 @@
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
use serde_json::{Value, json};
@@ -20,91 +17,60 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
]
}
#[cfg(not(target_arch = "wasm32"))]
fn printers() -> Value {
let run = |args: &[&str]| {
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
};
let names: Vec<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);
let printers = craft_print::printers();
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
json!({"printers": names, "default": default})
}
#[cfg(target_arch = "wasm32")]
fn printers() -> Value {
json!({"printers": [], "default": null})
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
#[cfg(target_arch = "wasm32")]
{
// Even a dry run cannot construct a native spool command in the browser.
let _ = opts;
super::export::options(p, s.doc()?.doc.page_count())?;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
{
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let printer = str_param(p, "printer").map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let mut file = secure_print_file()?;
let file_path = file.path().to_path_buf();
let mut cmd: Vec<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_path.to_string_lossy().to_string()]);
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
if dry {
return Ok(out);
}
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
if !st.status.success() {
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
}
Ok(out)
}
print_with(s, p, craft_print::submit)
}
#[cfg(not(target_arch = "wasm32"))]
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
let file = tempfile::Builder::new()
.prefix("designcraft-print-")
.suffix(".pdf")
.tempfile()
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
fn print_with(
s: &mut Session,
p: &Value,
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<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()))?;
}
Ok(file)
Ok(out)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
use crate::Session;
use super::*;
#[test]
fn print_builds_the_spool_command() {
@@ -112,37 +78,45 @@ mod tests {
s.execute("file.new", &json!({"pages": 4})).unwrap();
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
assert_eq!(r["pages"], 2);
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
assert!(cmd.last().unwrap().ends_with(".pdf"));
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
if craft_print::backend() == Some(craft_print::Backend::Cups) {
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
assert!(command.contains(&"print-scaling=auto-fit"));
assert!(!command.iter().any(|a| a.starts_with("sides=")));
} else {
assert_eq!(command[0], "powershell");
}
assert!(r["bytes"].as_u64().unwrap() > 500);
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn print_uses_distinct_ephemeral_files() {
let first = super::secure_print_file().unwrap();
let second = super::secure_print_file().unwrap();
let first_path = first.path().to_path_buf();
let second_path = second.path().to_path_buf();
assert_ne!(first_path, second_path);
drop((first, second));
assert!(!first_path.exists());
assert!(!second_path.exists());
}
#[cfg(all(not(target_arch = "wasm32"), unix))]
#[test]
fn print_file_is_private_and_removed_on_drop() {
use std::os::unix::fs::PermissionsExt;
let file = super::secure_print_file().unwrap();
let path = file.path().to_path_buf();
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
drop(file);
assert!(!path.exists());
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&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());
}
}
+23 -4
View File
@@ -716,13 +716,18 @@ 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 f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
}
Value::Object(o) => {
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
let g = |k: &str| o.get(k).and_then(Value::as_f64);
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
}
_ => None,
}
@@ -1040,6 +1045,20 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
mod color_tests {
use super::*;
#[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();
+4 -14
View File
@@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
}
for key in ["row", "story", "table"] {
if let Some(v) = p.get(key)
&& !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())
&& v.as_u64().is_none_or(|n| usize::try_from(n).is_err())
{
return Err(bad(ID, format!("{key} must be a non-negative integer")));
}
@@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<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 aid = designcraft_doc::AssetId(d.alloc());
let fresh = designcraft_doc::AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name,
mime,
link,
data: std::sync::Arc::new(bytes),
pixels: Some((pw, ph)),
}),
);
let aid =
d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) });
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
let (r, c) = t.owner(g.range.r0, g.range.c0);
+9 -1
View File
@@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() {
let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": b64})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content {
designcraft_doc::Content::Graphic(g) => g.xf,
_ => panic!("not a graphic"),
};
let placed = graphic_xf(&s);
// Dragging the top edge down crops the top only: the image stays where it is.
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap();
assert_eq!(graphic_xf(&s), placed);
}
#[test]
+136 -17
View File
@@ -2,7 +2,8 @@
//!
//! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip`
//! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`)
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte: one per distinct file, and only
//! the ones the document references (see [`Document::compact_assets`])
//! - `meta.json`: format version and generator
//!
//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents
@@ -10,7 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
@@ -43,8 +44,12 @@ fn ext_for(mime: &str) -> &'static str {
}
}
/// Serialize a document to `.designcraft` bytes.
/// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once
/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back).
pub fn save(doc: &Document) -> Result<Vec<u8>, FormatError> {
let mut compact = doc.clone();
compact.compact_assets();
let doc = &compact;
let io = |e: zip::result::ZipError| FormatError::Io(e.to_string());
let mut buf = Cursor::new(Vec::new());
{
@@ -101,14 +106,27 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
upgrade_json(&mut v);
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let names: Vec<String> = z.file_names().map(str::to_string).collect();
// Files saved with one copy per placement: identical copies share one buffer as they are
// read, so memory holds each distinct file once.
let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new();
for name in names {
let Some(stem) = name.strip_prefix("assets/") else { continue };
let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue };
if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) {
Arc::make_mut(a).data = Arc::new(data);
let same = loaded.entry(data.len()).or_default();
let data = match same.iter().find(|d| ***d == data) {
Some(d) => d.clone(),
None => {
let d = Arc::new(data);
same.push(d.clone());
d
}
};
Arc::make_mut(a).data = data;
}
}
upgrade(&mut doc, version);
doc.merge_duplicate_assets();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
@@ -174,6 +192,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
}
}
upgrade(&mut doc, 1);
doc.merge_duplicate_assets();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
@@ -255,24 +274,67 @@ mod tests {
use designcraft_doc::geom::Rect;
use designcraft_doc::{Asset, ParaFormat, SpreadRef};
fn png_asset(d: &mut Document, data: &[u8]) -> AssetId {
let aid = AssetId(d.alloc());
let a = Asset {
page: 0,
id: aid,
name: "x.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(data.to_vec()),
pixels: Some((1, 1)),
};
d.assets.insert(aid, Arc::new(a));
aid
}
fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId {
use designcraft_doc::geom::{Affine, shapes};
use designcraft_doc::{Content, Graphic, Item, ItemId, Shape};
let id = ItemId(d.alloc());
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
it.content = Content::Graphic(Graphic {
asset,
size: (1.0, 1.0),
xf: Affine::translate((crop, crop)),
auto_fit: Default::default(),
fit_align: 4,
crop: [crop; 4],
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
}
fn asset_entries(bytes: &[u8]) -> usize {
let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
z.file_names().filter(|n| n.starts_with("assets/")).count()
}
/// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned).
fn save_uncompacted(d: &Document) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default();
z.start_file("mimetype", opts).unwrap();
z.write_all(MIME.as_bytes()).unwrap();
z.start_file("document.json", opts).unwrap();
z.write_all(&serde_json::to_vec(d).unwrap()).unwrap();
for (id, a) in &d.assets {
z.start_file(format!("assets/{}.png", id.0), opts).unwrap();
z.write_all(&a.data).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
#[test]
fn roundtrip_with_assets() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap();
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "x.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(vec![1, 2, 3, 4]),
pixels: Some((1, 1)),
}),
);
let aid = png_asset(&mut d, &[1, 2, 3, 4]);
place(&mut d, aid, 0.0);
let bytes = save(&d).unwrap();
assert!(bytes.starts_with(b"PK"));
let back = load(&bytes).unwrap();
@@ -397,6 +459,63 @@ mod tests {
assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left);
}
#[test]
fn saves_hold_each_used_file_once() {
let mut d = Document::new(&NewDocument::default());
let first = png_asset(&mut d, &[7; 64]);
let copy = png_asset(&mut d, &[7; 64]);
let replaced = png_asset(&mut d, &[9; 64]);
let a = place(&mut d, first, 1.0);
let b = place(&mut d, copy, 2.0);
assert_eq!(d.assets.len(), 3);
// Duplicates merge into the first asset, the unreferenced one is left out, and every
// placement keeps its own crop and transform.
let bytes = save(&d).unwrap();
assert_eq!(asset_entries(&bytes), 1);
assert_eq!(d.assets.len(), 3, "the open document keeps its assets");
let back = load(&bytes).unwrap();
assert_eq!(back.assets.keys().copied().collect::<Vec<_>>(), vec![first]);
assert!(!back.assets.contains_key(&replaced));
for (id, crop) in [(a, 1.0), (b, 2.0)] {
let g = back.item(id).and_then(|i| i.graphic()).unwrap();
assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf));
}
}
#[test]
fn files_with_identical_bytes_at_different_paths_keep_their_paths() {
let mut d = Document::new(&NewDocument::default());
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect();
for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) {
if let Some(a) = d.assets.get_mut(id) {
Arc::make_mut(a).link = Some(link.into());
}
}
let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect();
for bytes in [save(&d).unwrap(), save_uncompacted(&d)] {
let back = load(&bytes).unwrap();
assert_eq!(back.assets.len(), 3);
let links: Vec<Option<&str>> = items
.iter()
.map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref())
.collect();
assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]);
}
}
#[test]
fn older_files_with_one_asset_per_placement_open_with_one() {
let mut d = Document::new(&NewDocument::default());
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect();
for (k, id) in ids.iter().enumerate() {
place(&mut d, *id, k as f64);
}
let back = load(&save_uncompacted(&d)).unwrap();
assert_eq!(back.assets.len(), 1);
assert_eq!(asset_entries(&save(&back).unwrap()), 1);
}
/// The object of the item `id` in a document's JSON.
fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> {
if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() {
+104 -37
View File
@@ -48,8 +48,24 @@ fn default_reader(path: &str) -> Option<Vec<u8>> {
/// Import with a custom reader for linked files (`path` → bytes).
pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Document> {
import_idml_report(bytes, read_link).map(|r| r.document)
}
/// An imported IDML document and what the import could not bring in.
#[derive(Debug)]
pub struct Imported {
pub document: Document,
/// One line per problem that did not stop the import (a part listed in `designmap.xml` but
/// absent from the package, …).
pub warnings: Vec<String>,
}
/// Import with a custom reader for linked files, reporting the problems that did not stop it.
pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Imported> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
// Normalised part name → entry name, first entry wins (zip order, so the choice is stable).
let mut lenient: HashMap<String, String> = HashMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
if f.is_dir() {
@@ -59,35 +75,81 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>
// The size is what the archive claims: don't reserve more than a sane part up front.
let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24));
f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?;
lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone());
files.insert(name, buf);
}
if let Some(m) = files.get("mimetype")
let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n)));
if let Some(m) = part("mimetype")
&& String::from_utf8_lossy(m).trim() != MIMETYPE
{
return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim())));
}
let dm = files.get("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?;
if root.local() != "Document" {
return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name)));
}
// Flatten includes: every `idPkg:*` child is replaced by the children of its part's root.
let mut top: Vec<El> = Vec::new();
let mut warnings = Vec::new();
for c in root.elements() {
if c.name.starts_with("idPkg:") {
let Some(src) = c.get("src") else { continue };
let Some(data) = files.get(src) else { continue };
let part = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
top.extend(part.elements().cloned());
let Some(data) = part(src) else {
warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported"));
continue;
};
let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
top.extend(el.elements().cloned());
} else {
top.push(c.clone());
}
}
let mut im = Importer::new(read_link);
im.run(&root, &top)?;
let d = im.finish(&root)?;
d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
Ok(d)
let document = im.finish(&root).map_err(|e| match e {
// Without its spread parts there is nothing to open: say which parts were missing.
IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))),
e => e,
})?;
document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
Ok(Imported { document, warnings })
}
/// A package part name in the form the lookup compares: InDesign and other IDML writers differ
/// in percent-encoding, case, path separators and a leading `./` or `/`.
fn normalize_part_name(name: &str) -> String {
let decoded = percent_decode(&name.replace('\\', "/"));
let mut rest = decoded.as_str();
loop {
if let Some(r) = rest.strip_prefix("./") {
rest = r;
} else if let Some(r) = rest.strip_prefix('/') {
rest = r;
} else {
break;
}
}
rest.to_lowercase()
}
/// Decode `%XX` escapes; anything else (including a malformed escape) is kept as written.
pub(crate) fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
if c == b'%'
&& let (Some(h), Some(l)) = (b.get(i + 1).and_then(|h| (*h as char).to_digit(16)), b.get(i + 2).and_then(|l| (*l as char).to_digit(16)))
{
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
out.push(c);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
struct ItemCtx {
@@ -132,6 +194,8 @@ struct Importer<'r> {
page_index: HashMap<String, usize>,
item_ids: HashMap<String, ItemId>,
assets: BTreeMap<AssetId, Arc<Asset>>,
/// Assets read from a link path, so later placements of that path reuse them unread.
linked_assets: HashMap<String, AssetId>,
ctx: ItemCtx,
sections: Vec<Section>,
footnote_options: designcraft_doc::FootnoteOptions,
@@ -220,6 +284,7 @@ impl<'r> Importer<'r> {
page_index: HashMap::new(),
item_ids: HashMap::new(),
assets: BTreeMap::new(),
linked_assets: HashMap::new(),
ctx: ItemCtx { threads: Vec::new() },
sections: Vec::new(),
footnote_options: Default::default(),
@@ -2235,12 +2300,29 @@ impl<'r> Importer<'r> {
let size = ((r - l).abs(), (b - t).abs());
let link = g.find("Link");
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
let graphic = |asset| {
Content::Graphic(designcraft_doc::Graphic {
asset,
size,
xf: gxf * Affine::translate((l, t)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
})
};
let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default();
let mut read_from = None;
if data.is_empty()
&& let Some(p) = &uri
&& let Some(d) = (self.read_link)(p)
{
data = d;
// Every placement of a linked file shares one asset; read the file once.
if let Some(id) = self.linked_assets.get(p) {
return graphic(*id);
}
if let Some(d) = (self.read_link)(p) {
data = d;
read_from = Some(p.clone());
}
}
let (mime, px) = sniff_image(&data);
let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") {
@@ -2256,17 +2338,14 @@ impl<'r> Importer<'r> {
});
let name =
uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into());
let id = AssetId(self.alloc());
let fresh = AssetId(self.alloc());
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels }));
Content::Graphic(designcraft_doc::Graphic {
asset: id,
size,
xf: gxf * Affine::translate((l, t)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
})
let id =
designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
if let Some(p) = read_from {
self.linked_assets.insert(p, id);
}
graphic(id)
}
fn link_threads(&mut self) {
@@ -2653,21 +2732,9 @@ fn path_of(pg: &El) -> PathData {
pub(crate) fn uri_to_path(uri: &str) -> String {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
// `file:///C:/…` → `/C:/…` → `C:/…`
let rest = if rest.len() > 3 && rest.starts_with('/') && rest.as_bytes()[2] == b':' { &rest[1..] } else { rest };
let mut out = Vec::with_capacity(rest.len());
let b = rest.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16)
{
out.push(v);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
let rest = match rest.as_bytes() {
[b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest),
_ => rest,
};
percent_decode(rest)
}
+1 -1
View File
@@ -49,7 +49,7 @@ mod xml;
mod tests;
pub use export::{ExportOptions, export_idml, export_idml_with};
pub use import::{import_idml, import_idml_with};
pub use import::{Imported, import_idml, import_idml_report, import_idml_with};
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
+55
View File
@@ -7,6 +7,7 @@ use designcraft_geom::{Rect, shapes};
use super::*;
mod assets;
mod decorations;
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
@@ -452,6 +453,60 @@ fn imports_scalar_insets_in_object_style_without_replacing_frame_override() {
assert_eq!(frame_inset(&d), [3.0; 4]);
}
/// A one-page spread part `Self` with one rectangle.
fn one_frame_spread(id: &str) -> String {
format!(
r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="{id}">
<Page Self="{id}p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/>
<Rectangle Self="{id}r" ItemTransform="1 0 0 1 0 0">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="10 10"/><PathPointType Anchor="10 50"/>
<PathPointType Anchor="50 50"/><PathPointType Anchor="50 10"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</Rectangle>
</Spread>
</idPkg:Spread>"#
)
}
/// Writers differ in how a designmap `src` spells its part: percent-encoded, another case,
/// backslashes, a leading `./`. Those resolve; a part absent from the package is a warning that
/// names it, not silently dropped objects.
#[test]
fn designmap_parts_match_leniently_and_missing_ones_are_reported() {
let designmap = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<idPkg:Spread src="Spreads/Spread_u1a%20b.xml"/>
<idPkg:Spread src="./spreads\SPREAD_u2.xml"/>
<idPkg:Spread src="Spreads/Spread_gone.xml"/>
</Document>"#;
let zip = zip_files(&[
("designmap.xml", designmap),
("Spreads/Spread_u1a b.xml", &one_frame_spread("u1")),
("Spreads/Spread_u2.xml", &one_frame_spread("u2")),
]);
let r = import_idml_report(&zip, &|_| None).unwrap();
assert_eq!(r.document.spreads.len(), 2, "both lenient spellings resolve");
assert!(r.document.spreads.iter().all(|sp| sp.items.len() == 1), "the spreads keep their frames");
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
assert!(r.warnings[0].contains("Spreads/Spread_gone.xml"), "{:?}", r.warnings);
// With every spread part missing the import fails, and the error names the part.
let only_missing = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<idPkg:Spread src="Spreads/Spread_gone.xml"/>
</Document>"#;
let err = import_idml(&zip_files(&[("designmap.xml", only_missing)])).unwrap_err();
assert!(err.to_string().contains("Spreads/Spread_gone.xml"), "{err}");
}
#[test]
fn percent_decode_keeps_malformed_escapes() {
assert_eq!(import::percent_decode("a%20b%2"), "a b%2");
assert_eq!(import::percent_decode("%é%zz"), "%é%zz");
assert_eq!(import::uri_to_path("file:///C:/a%20b"), "C:/a b");
assert_eq!(import::uri_to_path("file:/a%bé"), "/a%bé");
}
#[test]
fn rejects_non_idml() {
assert!(import_idml(b"not a zip").is_err());
+110
View File
@@ -0,0 +1,110 @@
//! Placed files: every placement of one file shares an asset.
use std::cell::Cell;
use designcraft_doc::{AssetId, Content};
use super::*;
const PHOTO: &str = "file:/photos/wedding%20day/couple.jpg";
/// A graphic frame at `x` showing `image` (an `<Image>` element) with `crop` on every side.
fn frame(n: usize, x: f64, crop: f64, image: &str) -> String {
format!(
r#"<Rectangle Self="r{n}" ItemLayer="L1" ItemTransform="1 0 0 1 {x} 0" ContentType="GraphicType">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<FrameFittingOption TopCrop="{crop}" LeftCrop="{crop}" BottomCrop="{crop}" RightCrop="{crop}"/>
{image}
</Rectangle>"#
)
}
fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String {
let contents = contents.map(|c| format!("<Contents><![CDATA[{}]]></Contents>", base64_encode(c))).unwrap_or_default();
format!(
r#"<Image Self="im{n}" ItemTransform="1 0 0 1 {n} 0">
<Properties>{contents}<GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
<Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/JPEG" StoredState="Normal"/>
</Image>"#
)
}
fn package(frames: &[String]) -> Vec<u8> {
let spread = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0">
<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 0"/>
{}
</Spread>
</idPkg:Spread>"#,
frames.join("\n")
);
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
])
}
/// (asset, crop, transform) of each graphic, left to right.
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => Some((i.xf.translation().x, g.asset, g.crop, g.xf)),
_ => None,
})
.collect();
out.sort_by(|a, b| a.0.total_cmp(&b.0));
out.into_iter().map(|(_, a, c, x)| (a, c, x)).collect()
}
#[test]
fn one_file_placed_several_times_imports_as_one_asset() {
let bytes = vec![42u8; 300];
let reads = Cell::new(0);
let reader = |p: &str| {
reads.set(reads.get() + 1);
p.ends_with("couple.jpg").then(|| bytes.clone())
};
// Three placements linking the file, and one embedding the same bytes under another path:
// that one keeps its own asset and path.
let frames = vec![
frame(1, 0.0, 1.0, &image(1, PHOTO, None)),
frame(2, 100.0, 2.0, &image(2, PHOTO, None)),
frame(3, 200.0, 3.0, &image(3, PHOTO, None)),
frame(4, 300.0, 4.0, &image(4, "file:/elsewhere/copy.jpg", Some(&bytes))),
];
let d = import_idml_with(&package(&frames), &reader).unwrap();
assert_eq!(reads.get(), 1, "the linked file is read once");
assert_eq!(d.assets.len(), 2);
let p = placements(&d);
assert_eq!(p.iter().map(|(_, c, _)| c[0]).collect::<Vec<_>>(), vec![1.0, 2.0, 3.0, 4.0]);
let link = |id| d.assets.get(id).and_then(|a| a.link.as_deref());
let links: Vec<_> = p.iter().map(|(id, _, _)| link(id)).collect();
assert_eq!(links[..3], [Some("/photos/wedding day/couple.jpg"); 3]);
assert_eq!(links[3], Some("/elsewhere/copy.jpg"));
assert!(p[..3].iter().all(|(id, _, _)| *id == p[0].0));
// Exported linked or embedded, the round trip keeps the two assets and each placement's crop
// and transform.
for embed_images in [false, true] {
let out = export_idml_with(&d, &ExportOptions { embed_images });
let back = import_idml_with(&out, &reader).unwrap();
assert_eq!(back.assets.len(), 2, "embed_images: {embed_images}");
let q = placements(&back);
assert_eq!(q.len(), 4);
for ((_, c0, x0), (_, c1, x1)) in p.iter().zip(&q) {
assert_eq!(c0, c1);
assert!(x0.as_coeffs().iter().zip(x1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{x0:?} {x1:?}");
}
}
}
+54 -6
View File
@@ -78,15 +78,11 @@ impl Backend for Remote {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
// One retry with a fresh connection (the app may have restarted).
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err(_) => {
Err(e) => {
self.conn = None;
self.roundtrip(&line).map_err(|e| {
self.conn = None;
format!("DesignCraft app at {} is not reachable: {e}", self.addr)
})?
return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr));
}
};
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
@@ -105,3 +101,55 @@ impl Backend for Remote {
format!("connected to the DesignCraft app at {}", self.addr)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn remote_does_not_replay_when_reply_is_lost() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = std::thread::spawn(move || {
let first = {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
let request: Value = serde_json::from_str(&line).unwrap();
assert_eq!(request["method"], "engine.execute");
assert_eq!(request["params"]["command"], "frame.create");
request
};
let mut applied = 1;
let (mut stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
let request: Value = serde_json::from_str(&line).unwrap();
if request == first {
applied += 1;
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
} else {
assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}}));
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
return applied;
}
}
});
let mut remote = Remote::connect(&addr).unwrap();
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
let next = remote.call("document.inspect", json!({}));
let applied = server.join().unwrap();
assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}");
assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}");
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
}
}
+9
View File
@@ -1022,6 +1022,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
designcraft_images::pixel_size(bytes)
}
/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display.
pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
if is_pdf(bytes) {
return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3)));
}
designcraft_images::natural_size(bytes)
}
/// MIME type guess for encoded image bytes.
/// Is this a PDF file (placed PDFs are graphics)?
pub fn is_pdf(bytes: &[u8]) -> bool {
+3
View File
@@ -2,6 +2,7 @@
name = "designcraft-ui-egui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it"
@@ -9,6 +10,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth
workspace = true
[dependencies]
craft-ui = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
@@ -28,6 +30,7 @@ log = { workspace = true }
[dev-dependencies]
designcraft-idml = { workspace = true }
zip = { workspace = true }
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
designcraft-fonts = { workspace = true, features = ["testing"] }
+2 -2
View File
@@ -1452,8 +1452,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
if resp.clicked() || resp.drag_started() {
resp.request_focus();
}
// Keyboard for the active tool.
if ui.ctx().text_edit_focused() && !resp.has_focus() {
// Both split panes show the same document, but only the focused pane dispatches its keys.
if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) {
return;
}
let evs = ui.input(|i| i.events.clone());
+1 -1
View File
@@ -278,7 +278,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
app.ui.control_bar = v;
}
if p.get("closePanel").is_some() {
app.ui.open_panel = None;
r = crate::panel_docking::close_visible(app).map(|_| Value::Null);
}
if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) {
app.ui.dock_expanded = v;
+125 -7
View File
@@ -49,6 +49,9 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
];
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
let t = Tokens::get(ui.ctx());
// Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right.
if app.ui.dock_expanded {
@@ -66,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.ctx(),
crate::i18n::tr(&app.ui.language, label),
semibold(11.5),
if active { Color32::from_rgb(0xf3, 0xf3, 0xf3) } else { t.text_dim },
if active { t.text_strong } else { t.text_dim },
);
let w = g.size().x + 26.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0));
@@ -75,9 +78,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() {
let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at(
ui,
r,
|_, _| {},
);
if response.clicked() {
app.ui.dock_tab = id.to_string();
}
crate::panel_docking::legacy_tab(app, ui, id, &response);
x += w;
}
let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0));
@@ -100,13 +109,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
for (id, label, icon) in ICON_PANELS {
let open = app.ui.open_panel.as_deref() == Some(*id);
let floats = app.ui.floating.iter().any(|(p, _)| p == id);
if icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).clicked() {
let response =
icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());
if response.clicked() {
if floats {
ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id))));
} else {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
}
crate::panel_docking::legacy_tab(app, ui, id, &response);
ui.add_space(1.0);
}
});
@@ -123,6 +135,9 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) {
/// A panel opened from the icon column, shown as a floating flyout next to the dock.
pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
if app.ui.docking.is_some() {
return;
}
let Some(id) = app.ui.open_panel.clone() else { return };
let t = Tokens::get(ctx);
let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned();
@@ -170,6 +185,9 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
/// The contents of an icon-column panel.
pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
match id {
"properties" => panels::properties::show(app, ui),
"pages" => panels::pages::show(app, ui),
"layers" => panels::layers::show(app, ui),
"swatches" => panels::swatches::show(app, ui),
"paragraphStyles" => panels::styles::paragraph(app, ui),
"characterStyles" => panels::styles::character(app, ui),
@@ -209,20 +227,27 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
/// Tear panel `id` off the dock into a floating window at `at`.
pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) {
if app.ui.open_panel.as_deref() == Some(id) {
app.ui.open_panel = None;
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) {
app.status(error);
}
app.ui.floating.retain(|(p, _)| p != id);
app.ui.floating.push((id.to_string(), [at.x, at.y]));
}
/// Put a floating panel back in the dock's icon column.
pub fn dock_panel(app: &mut DesignApp, id: &str) {
if app.ui.docking.is_some() {
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) {
app.status(error);
}
return;
}
app.ui.floating.retain(|(p, _)| p != id);
}
/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column.
pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
if app.ui.docking.is_some() {
return;
}
let t = Tokens::get(ctx);
for (id, at) in app.ui.floating.clone() {
let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned();
@@ -291,3 +316,96 @@ fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) {
p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle);
p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c);
}
#[cfg(test)]
mod visual_regressions {
use super::*;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
/// Opt-in evidence for native screenshot review; ordinary test runs stay headless and fast.
#[test]
fn capture_dock_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for brightness in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
app.ui.dock_tab = "properties".into();
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), "en");
crate::theme::apply(ui.ctx(), &Tokens::for_brightness(brightness));
ready = true;
return;
}
show(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id());
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect;
let region = serde_json::json!({
"screen": [width, 640.0], "scale": scale,
"regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]}
});
std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(&region).unwrap()).unwrap();
}
}
}
}
fn luminance(c: Color32) -> f32 {
let linear = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b())
}
#[test]
fn selected_dock_tab_has_readable_rendered_contrast_in_every_theme() {
for brightness in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
let tokens = Tokens::for_brightness(brightness);
crate::theme::apply(&ctx, &tokens);
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
app.ui.dock_tab = "properties".into();
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
let text = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text == "Properties" => Some(text),
_ => None,
})
.expect("the active Properties tab is painted");
let color = text.galley.job.sections.first().expect("tab text has a format").format.color;
let a = luminance(color);
let b = luminance(tokens.panel);
let contrast = (a.max(b) + 0.05) / (a.min(b) + 0.05);
assert!(contrast >= 4.5, "{brightness:?} at {width}: selected tab contrast is {contrast:.2}");
}
}
}
}
}
+159 -12
View File
@@ -16,7 +16,9 @@ pub mod dock;
pub mod i18n;
pub mod icons;
pub mod menus;
mod panel_docking;
pub mod panels;
mod prefs;
pub mod render_worker;
mod rtl;
pub mod story_editor;
@@ -97,6 +99,11 @@ pub struct SavedWorkspace {
pub dock_expanded: bool,
pub open_panel: Option<String>,
pub floating: Vec<(String, [f32; 2])>,
/// Custom panel membership and placement; absent in older workspace files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
}
/// A missing on-by-default bool stays on. `bool::default` is false.
@@ -217,6 +224,20 @@ pub struct UiState {
pub open_panel: Option<String>,
/// Panels torn off the dock into their own floating windows: (panel id, top-left position).
pub floating: Vec<(String, [f32; 2])>,
/// Shared panel layout after the user customizes the legacy dock.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// One-frame reveal request; z-order is never persisted.
#[serde(skip)]
pub docking_raise: Option<String>,
/// Last explicitly revealed customized panel, for the legacy closePanel control.
#[serde(skip)]
pub docking_opened: Option<String>,
pub dock_expanded: bool,
pub units: Unit,
pub workspace: String,
@@ -310,6 +331,11 @@ impl Default for UiState {
dock_tab: "properties".into(),
open_panel: None,
floating: Vec::new(),
docking: None,
docking_hidden: Default::default(),
docking_generation: 0,
docking_raise: None,
docking_opened: None,
dock_expanded: true,
units: Unit::Picas,
workspace: "Essentials".into(),
@@ -786,6 +812,7 @@ impl DesignApp {
/// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
/// that can't be opened or placed gets an alert with the reason, as well as the status line.
/// A successful import with warnings keeps the document and shows the warnings in an alert.
pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
let file = match params.get("path").and_then(Value::as_str) {
Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
@@ -795,6 +822,20 @@ impl DesignApp {
if let Err(e) = &r {
let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e})));
} else if let Ok(result) = &r {
let warnings: Vec<&str> = result
.get("warnings")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter(|warning| !warning.is_empty())
.collect();
if !warnings.is_empty() {
self.status(format!("{file}: {}", warnings.join("; ")));
self.ui.dialog =
Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")})));
}
}
r
}
@@ -824,6 +865,9 @@ impl DesignApp {
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
let presenting = self.ui.screen_mode == ScreenMode::Presentation;
if presenting || self.ui.hidden_panels != 0 {
panel_docking::cancel_gesture(self, &ctx);
}
let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some();
if dbg {
eprintln!("root start {:?}", ui.available_rect_before_wrap());
@@ -855,16 +899,29 @@ impl DesignApp {
if !presenting {
chrome::doc_tabs(self, ui);
}
if self.split && !presenting {
let split_layout = (self.split && !presenting)
.then(|| {
craft_ui::layout::split_rect(
ui.available_rect_before_wrap(),
craft_ui::layout::SplitAxis::Horizontal,
craft_ui::layout::SplitSize::Ratio(0.5),
2.0,
0.0,
)
})
.flatten();
if let Some(layout) = split_layout {
// Two panes with a divider; each has its own view (zoom, scroll) and render cache.
let r = ui.available_rect_before_wrap();
let mid = r.center().x;
let halves = [
egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)),
egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max),
];
ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border);
for (k, half) in halves.into_iter().enumerate() {
ui.painter().rect_filled(layout.divider, 0.0, t.border);
// Resolve the clicked pane before either canvas handles this frame's keys.
if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) {
if layout.first.contains(p) {
self.focus_pane = 0;
} else if layout.second.contains(p) {
self.focus_pane = 1;
}
}
for (k, half) in [layout.first, layout.second].into_iter().enumerate() {
self.switch_pane(k as u8);
// A new pane starts at the other pane's view.
if k == 1 && self.view().is_none() {
@@ -877,9 +934,6 @@ impl DesignApp {
}
}
ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui));
if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) {
self.focus_pane = k as u8;
}
}
self.switch_pane(self.focus_pane);
} else {
@@ -1280,6 +1334,99 @@ mod tests {
}
}
#[cfg(test)]
mod import_warning_tests {
use super::*;
use std::io::Write;
fn idml(missing_parts: bool) -> Vec<u8> {
let missing = if missing_parts { r#"<idPkg:Spread src="Spreads/missing.xml"/><idPkg:Story src="Stories/missing.xml"/>"# } else { "" };
let map = format!(
r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"><idPkg:Spread src="Spreads/one.xml"/>{missing}</Document>"#
);
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="s"><Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/><Rectangle Self="r" ItemTransform="1 0 0 1 0 0"/></Spread></idPkg:Spread>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, text) in [("mimetype", designcraft_idml::MIMETYPE), ("designmap.xml", &map), ("Spreads/one.xml", spread)] {
zip.start_file(name, options).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn app() -> DesignApp {
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
app.run("file.new", json!({"title": "Existing work"})).unwrap();
app.run("frame.create", json!({"rect": [10, 10, 50, 50]})).unwrap();
app
}
fn assert_partial_import_warning(app: &mut DesignApp, file: &str) {
let dialog = app.ui.dialog.as_ref().expect("partial import must show an alert");
assert_eq!(dialog.id, "alert");
assert_eq!(dialog.fields["title"], "Import Warnings");
assert_eq!(dialog.fields["file"], file);
for part in ["Spreads/missing.xml", "Stories/missing.xml"] {
assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named");
assert!(app.ui.status.contains(part), "the status line retains the warning");
}
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.session.documents()[0].doc.title, "Existing work");
assert!(app.session.documents()[0].is_dirty(), "existing unsaved work stays intact");
assert_eq!(app.session.documents()[0].doc.spreads[0].items.len(), 1);
assert_eq!(app.session.doc().unwrap().doc.spreads.len(), 1);
assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "available document content is kept");
dialogs::confirm(app).unwrap();
assert!(app.ui.dialog.is_none(), "OK dismisses the warning");
assert_eq!(app.session.documents().len(), 2, "dismissing the warning keeps the imported document");
}
#[test]
fn byte_open_reports_partial_import_warnings_without_losing_documents() {
let mut app = app();
let result =
app.open_file("file.openBytes", json!({"name": "partial.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(true))})).unwrap();
assert_eq!(result["warnings"].as_array().unwrap().len(), 2);
assert_partial_import_warning(&mut app, "partial.idml");
}
#[test]
fn native_open_reports_partial_import_warnings_without_losing_documents() {
let path = std::env::temp_dir().join(format!("designcraft-partial-import-{}", std::process::id())).join("partial.idml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, idml(true)).unwrap();
let picked = path.to_string_lossy().to_string();
let mut app = app();
app.services.pick_open = Some(Box::new(move |_| Some(picked.clone())));
let result = app.run("app.openDialog", json!({}));
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(path.parent().unwrap()).unwrap();
assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2);
assert_partial_import_warning(&mut app, "partial.idml");
}
#[test]
fn inbox_open_reports_partial_import_warnings_without_losing_documents() {
let mut app = app();
let request = app.import_request("open");
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) });
app.drain_inbox();
assert_partial_import_warning(&mut app, "partial");
}
#[test]
fn complete_import_does_not_show_a_warning() {
let mut app = app();
let result = app
.open_file("file.openBytes", json!({"name": "complete.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(false))}))
.unwrap();
assert_eq!(result["warnings"], json!([]));
assert!(app.ui.dialog.is_none());
assert!(app.ui.status.is_empty());
assert_eq!(app.session.documents().len(), 2);
}
}
/// The whole window in a headless test harness, at a chosen window size.
#[cfg(test)]
pub(crate) mod test_window {
+195
View File
@@ -130,6 +130,22 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}",
),
("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"),
(
"window.panel.layout",
"Change Panel Layout",
None,
"{action} — serialized docking action, including floating geometry, divider sizes and accordion state",
),
(
"window.panel.move",
"Move Panel",
None,
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel",
),
("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"),
("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"),
("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"),
("window.panel.close", "Close Panel", None, "{panel} — hide a panel"),
("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"),
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"),
@@ -707,6 +723,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> {
/// Run a UI command; `None` if `id` isn't one.
pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
let rect = app.canvas_rect;
let flag = |b: &mut bool| {
*b = !*b;
@@ -1379,6 +1398,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
dock_expanded: u.dock_expanded,
open_panel: u.open_panel.clone(),
floating: u.floating.clone(),
docking: u.docking.clone(),
docking_hidden: u.docking_hidden.clone(),
};
app.ui.custom_workspaces.retain(|x| x.name != name);
app.ui.custom_workspaces.push(w);
@@ -1402,6 +1423,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
return run_ui(app, "window.workspace", &json!({"name": name}));
}
"window.workspace" => {
app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1);
app.ui.docking_raise = None;
app.ui.docking_opened = None;
let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials");
if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() {
app.ui.control_bar = w.control_bar;
@@ -1412,10 +1436,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.dock_expanded = w.dock_expanded;
app.ui.open_panel = w.open_panel;
app.ui.floating = w.floating;
app.ui.docking = w.docking;
app.ui.docking_hidden = w.docking_hidden;
app.ui.workspace = w.name;
return Some(Ok(Value::Null));
}
// Workspaces choose which bars and panels are visible.
if app.ui.docking.is_some() {
app.ui.floating.clear();
}
app.ui.docking = None;
app.ui.docking_hidden.clear();
app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book");
let dock_tab = match name {
"Typography" => Some("properties"),
@@ -2440,6 +2471,170 @@ mod tests {
frame(&mut app);
}
#[test]
fn split_window_dispatches_text_once_to_the_shared_document() {
for focus_pane in [0, 1] {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let made = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let story = made["story"].as_u64().unwrap();
app.session.set_tool("type");
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap();
assert!(app.session.wants_text());
app.run("window.split", json!({"on": true})).unwrap();
app.focus_pane = focus_pane;
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![]);
let undo_before = app.session.active().unwrap().history.undo.len();
frame(&mut app, vec![egui::Event::Text("A".into())]);
let text = app.run("story.get", json!({"story": story})).unwrap();
assert_eq!(text["text"], "A", "one key press with pane {focus_pane} focused");
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "");
// A press and key arriving together must use the newly focused pane, whichever
// canvas is drawn first. Middle-click changes pane focus without moving the caret.
let other = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center();
frame(&mut app, vec![egui::Event::PointerMoved(other)]);
frame(
&mut app,
vec![
egui::Event::PointerButton { pos: other, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE },
egui::Event::Text("B".into()),
],
);
assert_eq!(app.focus_pane, 1 - focus_pane);
assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "B", "a key in the pane-switch frame is dispatched once");
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1);
}
}
#[test]
fn split_window_click_focus_routes_menu_zoom_but_hover_scroll_stays_local() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("window.split", json!({"on": true})).unwrap();
let ctx = egui::Context::default();
let mut time = 0.0;
let mut frame = |app: &mut crate::DesignApp, events| {
time += 0.1;
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
for _ in 0..3 {
frame(&mut app, vec![]);
}
let left = app.canvas_rect.unwrap().center();
let right = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center();
let cameras = |app: &mut crate::DesignApp| {
let current = app.pane;
app.switch_pane(0);
let left = *app.view().unwrap();
app.switch_pane(1);
let right = *app.view().unwrap();
app.switch_pane(current);
[left, right]
};
let button =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut app, vec![egui::Event::PointerMoved(right)]);
frame(&mut app, vec![button(right, true)]);
frame(&mut app, vec![button(right, false)]);
assert_eq!((app.pane, app.focus_pane), (1, 1));
let before = cameras(&mut app);
app.run("view.zoom", json!({"zoom": 2.0})).unwrap();
let zoomed = cameras(&mut app);
assert_eq!(zoomed[0], before[0], "menu zoom leaves the other camera alone");
assert!((zoomed[1].zoom - 2.0).abs() < 1e-9);
frame(&mut app, vec![egui::Event::PointerMoved(left)]);
frame(
&mut app,
vec![egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -40.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
}],
);
let scrolled = cameras(&mut app);
assert_eq!(scrolled[1], zoomed[1], "hover scroll leaves the focused camera alone");
assert_ne!(scrolled[0].origin, zoomed[0].origin);
assert_eq!((app.pane, app.focus_pane), (1, 1), "hovering does not move menu focus");
frame(&mut app, vec![button(left, true)]);
frame(&mut app, vec![button(left, false)]);
assert_eq!((app.pane, app.focus_pane), (0, 0));
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
let final_views = cameras(&mut app);
assert!((final_views[0].zoom - 1.0).abs() < 1e-9);
assert_eq!(final_views[1], scrolled[1]);
}
#[test]
fn split_window_contextual_bars_have_independent_widget_ids() {
fn id_warnings(shape: &egui::Shape, found: &mut Vec<String>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| id_warnings(shape, found)),
egui::Shape::Text(text) if text.galley.text().contains("use of widget ID") => found.push(text.galley.text().to_string()),
_ => {}
}
}
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
for rect in [[36, 36, 280, 180], [300, 36, 550, 180]] {
app.run("frame.create", json!({"rect": rect, "content": "unassigned"})).unwrap();
}
app.run("edit.selectAll", json!({})).unwrap();
app.run("window.split", json!({"on": true})).unwrap();
let ctx = egui::Context::default();
ctx.options_mut(|options| options.warn_on_id_clash = true);
let mut warnings = Vec::new();
for _ in 0..4 {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
let mut output = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
warnings.clear();
for shape in &output.shapes {
id_warnings(&shape.shape, &mut warnings);
}
output.textures_delta.clear();
}
assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}");
let bars: Vec<_> =
(0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect();
assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}");
}
#[test]
fn interface_language_switches_and_validates() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+515
View File
@@ -0,0 +1,515 @@
//! Application-owned panel IDs and commands around the shared docking layout.
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
use serde_json::{Value, json};
use crate::DesignApp;
#[derive(Clone, Default)]
struct LegacyDrag {
layout: Option<Layout<String>>,
open_panel: Option<String>,
floating: Vec<(String, [f32; 2])>,
generation: u64,
}
pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) {
cancel_legacy_drag(app, ctx);
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking"));
}
fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool {
let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin");
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
if let Some(previous) = previous {
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking"));
if previous.generation != app.ui.docking_generation {
return false;
}
app.ui.open_panel = previous.open_panel;
app.ui.floating = previous.floating;
app.ui.docking = previous.layout;
true
} else {
false
}
}
fn normalize(raw: &str) -> Option<&'static str> {
crate::dock::DOCK_TABS
.iter()
.chain(crate::dock::ICON_PANELS)
.find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim()))
.map(|(id, _, _)| *id)
}
pub(crate) fn valid(layout: &Layout<String>) -> bool {
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
}
fn legacy(app: &DesignApp) -> Layout<String> {
let panels: Vec<String> = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect();
let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0);
let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() };
for (id, at) in &app.ui.floating {
if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) {
layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] });
}
}
if let Some(id) = &app.ui.open_panel
&& normalize(id).is_some()
&& !layout.contains(id)
{
// Preserve the visible flyout when the first unrelated docking gesture succeeds.
if let Some(Node::Tabs { panels, active }) = &mut layout.root {
panels.push(id.clone());
*active = panels.len().saturating_sub(1);
}
}
layout
}
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
}
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
let anchor = first_root(layout, panel);
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
}
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
match params.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|v| v.is_finite() && v.abs() <= 1_000_000.0)
.map(|v| v as f32)
.ok_or_else(|| format!("{key} must be a finite coordinate")),
}
}
fn placement(params: &Value) -> Result<Placement<String>, String> {
if let Some(before) = params.get("before") {
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
return Ok(Placement::Tab { before: Some(id.into()) });
}
let zone = match params.get("zone") {
None => "tab",
Some(value) => value.as_str().ok_or("zone must be a string")?,
};
Ok(match zone {
"tab" => Placement::Tab { before: None },
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be tab, center, left, right, top or bottom".into()),
})
}
pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if id == "app.tablePanel" && app.ui.docking.is_some() {
let panel = "table".to_string();
let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel));
return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel})));
}
if id == "window.panel.layout" {
return Some(
params
.get("action")
.ok_or_else(|| "action is required".to_string())
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
.and_then(|action| apply_action(app, action)),
);
}
let operation = match id {
"window.panel.move" => "move",
"window.panel.float" | "window.floatPanel" => "float",
"window.panel.dock" | "window.dockPanel" => "dock",
"window.panel.activate" => "activate",
"window.panel.close" => "close",
"window.panel" if app.ui.docking.is_some() => "activate",
_ => return None,
};
Some(change(app, operation, params))
}
// UI output and automation share this atomic dispatcher. The application owns the registry
// and saved return locations; craft-ui owns tree validation and structural changes.
fn apply_action(app: &mut DesignApp, action: Action<String>) -> Result<Value, String> {
let registered = |id: &String| normalize(id) == Some(id.as_str());
let known = match &action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
registered(panel)
&& registered(anchor)
&& match placement {
Placement::Tab { before: Some(id) } => registered(id),
_ => true,
}
}
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
Action::Float { panel, .. }
| Action::Close { panel }
| Action::Activate { panel }
| Action::MoveFloating { panel, .. }
| Action::SetStackOpen { panel, .. }
| Action::ResizeStack { panel, .. } => registered(panel),
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
};
if !known {
return Err("docking action contains an unknown panel or an excessive split path".into());
}
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let returning = match &action {
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
Action::Float { panel, .. } => {
layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location))
}
_ => None,
};
let reveal = match &action {
Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
if let Some((panel, location)) = returning {
app.ui.docking_hidden.insert(panel, location);
}
app.ui.docking = Some(layout);
sync_legacy(app);
if let Some(panel) = reveal {
app.ui.docking_opened = Some(panel.clone());
app.ui.docking_raise = Some(panel);
app.ui.dock_expanded = true;
} else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) {
app.ui.docking_opened = None;
}
app.ui.open_panel = None;
Ok(json!({"layout": app.ui.docking}))
}
fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let mut hidden: std::collections::BTreeMap<_, _> =
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
let mut restored = false;
if operation == "dock"
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
&& let Some(location) = hidden.get(panel).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
})
{
let mut candidate = layout.clone();
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
if candidate.restore(panel.into(), location).is_ok() {
layout = candidate;
restored = true;
hidden.remove(panel);
}
}
match operation {
"dock" if restored => {}
"float" => {
let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?];
ensure(&mut layout, panel)?;
if let Ok(location) = layout.location(&panel.to_string())
&& location.floating.is_none()
{
hidden.insert(panel.into(), location);
}
layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?;
}
"move" | "dock" => {
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
return Err("move requires a destination panel".into());
}
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
None => first_root(&layout, panel),
};
let placement = placement(params)?;
if let Some(anchor) = anchor {
ensure(&mut layout, panel)?;
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
} else if operation == "dock" {
if layout.contains(&panel.to_string()) {
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
} else {
return Err("move requires an existing destination panel".into());
}
}
"close" => {
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
_ => {
if !layout.contains(&panel.to_string())
&& let Some(location) = hidden.get(panel)
{
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
let _ = layout.restore(panel.into(), location);
}
ensure(&mut layout, panel)?;
hidden.remove(panel);
}
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
sync_legacy(app);
app.ui.open_panel = None;
app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) };
app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) };
if operation != "close" {
app.ui.dock_expanded = true;
}
Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::<Vec<_>>(), "layout": app.ui.docking}))
}
pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool {
let area = egui::Id::new("designcraft-panel-docking");
let changed_workspace = ui.ctx().data_mut(|data| {
let previous = data.get_temp::<u64>(area.with("generation"));
data.insert_temp(area.with("generation"), app.ui.docking_generation);
previous.is_some_and(|generation| generation != app.ui.docking_generation)
});
if changed_workspace {
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
}
let cancelled =
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
return false;
}
let Some(layout) = app.ui.docking.as_ref() else { return false };
if !valid(layout) {
app.ui.docking = None;
app.status("Invalid panel layout; restored the default dock");
return false;
}
let mut layout = layout.clone();
if !app.ui.dock_expanded {
layout.root = None;
}
let raise = app.ui.docking_raise.take();
let mut blocked = Vec::new();
if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) {
blocked.push(ui.ctx().content_rect());
} else if app.story_editor.is_some() {
// The editor is rendered after the dock; block its unmeasured first frame conservatively.
let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect());
blocked.push(rect);
}
let t = crate::theme::Tokens::get(ui.ctx());
let language = app.ui.language.clone();
let mut draw = |ui: &mut egui::Ui| {
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = 27.0;
style.background = t.panel;
style.tab_background = t.tab_strip;
style.active_background = t.panel;
style.text = t.text_strong;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned();
style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned();
style.move_label = crate::i18n::tr(&language, "Group with").to_owned();
style.panels_label = crate::i18n::tr(&language, "Panels").to_owned();
style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned();
style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned();
let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked);
if let Some(panel) = &raise {
area = area.raise_panel(panel.clone());
}
area.show_customized(
ui,
&layout,
&style,
|id| {
crate::i18n::tr(
&language,
crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id),
)
.to_owned()
},
|_| craft_ui::docking::Permissions::default(),
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
&mut PanelContent(app),
)
};
let output = if layout.root.is_some() {
egui::Panel::right("shared-panel-dock")
.default_size(310.0)
.size_range(220.0..=720.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, &mut draw)
.inner
} else {
draw(ui)
};
if let Some(panel) = output.focused {
app.ui.docking_opened = Some(panel);
}
if let Some(error) = output.error {
app.ui.status = error.to_string();
}
if ui.input(|input| input.pointer.any_released()) {
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) {
return app.ui.docking.is_some();
}
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("designcraft-panel-docking").with("legacy-origin")));
}
for action in output.actions {
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
app.ui.status = error;
}
}
icon_rail(app, ui);
true
}
fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let column = egui::Panel::right("shared-panel-icons")
.exact_size(38.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
if app.ui.docking.as_ref().is_some_and(|layout| {
layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id)))
}) {
continue;
}
let response =
crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());
if response.clicked() {
let _ = app.run("window.panel.activate", json!({"panel":id}));
}
legacy_tab(app, ui, id, &response);
}
});
})
.response
.rect;
let _ = column;
}
pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
let Some(panel) = normalize(panel) else { return };
let language = app.ui.language.clone();
response.context_menu(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() {
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
app.status(error);
}
ui.close();
}
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() {
// A closed icon panel is made visible before becoming the destination.
let result = app
.run("window.panel.activate", json!({"panel": anchor}))
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
if let Err(error) = result {
app.status(error);
}
ui.close();
}
}
});
});
});
if response.drag_started()
&& let Some(id) = normalize(panel)
{
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let bounds = ui.max_rect();
let source = egui::Rect::from_min_size(
egui::pos2(bounds.left(), response.rect.top()),
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
);
if ensure(&mut layout, id).is_ok()
&& craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source)
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("designcraft-panel-docking").with("legacy-origin"),
LegacyDrag {
layout: app.ui.docking.clone(),
open_panel: app.ui.open_panel.clone(),
floating: app.ui.floating.clone(),
generation: app.ui.docking_generation,
},
)
});
app.ui.docking = Some(layout);
app.ui.open_panel = None;
}
}
}
struct PanelContent<'a>(&'a mut DesignApp);
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| {
egui::ScrollArea::vertical()
.id_salt(("shared-panel-body", id))
.auto_shrink([false, false])
.show(ui, |ui| crate::dock::panel_body(self.0, ui, id));
});
}
fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color))
}
fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color))
}
}
/// Close the last explicitly opened panel through the same validated layout transaction.
pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> {
if app.ui.docking.is_some() {
if let Some(panel) = app.ui.docking_opened.clone() {
change(app, "close", &json!({"panel": panel}))?;
}
} else {
app.ui.open_panel = None;
}
Ok(())
}
// Legacy inspection fields are derived from the shared layout after customization.
fn sync_legacy(app: &mut DesignApp) {
if let Some(layout) = &app.ui.docking {
app.ui.floating =
layout.floating.iter().flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))).collect();
}
}
#[cfg(test)]
#[path = "panel_docking_tests.rs"]
mod tests;
+599
View File
@@ -0,0 +1,599 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> DesignApp {
DesignApp::new(designcraft_engine::Session::new(), Default::default())
}
#[test]
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
let before_float = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
let layout = app.ui.docking.as_ref().unwrap();
assert!(valid(layout));
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
assert_eq!(unique.len(), layout.panels().len());
let saved = serde_json::to_value(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(restored.docking, app.ui.docking);
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
}
#[test]
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
let mut app = app();
for (id, params) in [
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
("window.panel.float", json!({"panel":"layers","width":-10})),
("window.panel.float", json!({"panel":"unknown"})),
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
] {
let old = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
assert!(app.run(id, params).is_err(), "{id}");
assert_eq!(app.ui.docking, old);
assert_eq!(app.ui.docking_hidden, hidden);
}
}
fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> {
let mut ready = false;
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark));
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
harness.run_steps(4);
harness
}
fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
drag(&mut h, source, egui::pos2(160.0, 140.0));
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, source, destination);
let layout = h.state().ui.docking.as_ref().unwrap();
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
assert!(valid(layout));
}
#[test]
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
let layout = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
app.run("window.newWorkspace", json!({"name":"Custom docking"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
assert!(app.ui.docking.is_none());
app.run("window.workspace", json!({"name":"Custom docking"})).unwrap();
assert_eq!(app.ui.docking, layout);
assert_eq!(app.ui.docking_hidden, hidden);
}
/// Explicit offscreen evidence; this creates no native windows.
#[test]
fn capture_custom_panel_docking_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for theme in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap();
let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" };
app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme));
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Custom panel workspace");
if app.ui.language == "ar" {
ui.label("Shaped action references:");
for action in ["Float Panel", "Group with", "Close Panel"] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action));
}
}
});
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(5);
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
harness
.render()
.unwrap()
.save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id())))
.unwrap();
if language == "ar" {
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string()));
let at = harness.ctx.read_response(tab).unwrap().rect.center();
harness.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
harness.run_steps(1);
harness.input_mut().events.push(Event::PointerButton {
pos: at,
button: PointerButton::Secondary,
pressed: false,
modifiers: Modifiers::NONE,
});
harness.run_steps(2);
assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open");
for action in ["Float Panel", "Group with", "Close Panel"] {
assert!(
harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2,
"both logical reference and actual menu label must exist"
);
}
harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap();
}
}
}
}
}
#[test]
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
assert!(h.state().ui.docking.is_some());
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.docking.is_none());
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().ui.docking.is_none());
}
#[test]
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
);
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
assert_eq!(
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
[44.0, 66.0, 360.0, 450.0]
);
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}}))
.unwrap();
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
let before = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
for action in [
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
json!({"Open":{"panel":"unknown","anchor":null}}),
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
] {
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
assert_eq!(app.ui.docking, before);
assert_eq!(app.ui.docking_hidden, hidden);
}
app.ui.docking = Some(Layout {
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
floating: Vec::new(),
});
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
assert!(!entries[0].open);
assert_eq!(entries[0].height, Some(150.0));
}
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
_ => {}
}
}
Vec::new()
}
#[test]
fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
let mut app = app();
app.run("file.new", json!({"width":480,"height":360})).unwrap();
let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap();
app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap();
app.run("object.stroke", json!({"weight":8})).unwrap();
app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap();
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
let builder = if directory.is_some() { builder.wgpu() } else { builder };
let mut h = builder.build_ui_state(
|ui, app: &mut DesignApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
let at = h.get_by_label("Bevel").rect().center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel);
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap();
}
let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, from, to);
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel);
if let Some(directory) = &directory {
h.render().unwrap().save(directory.join("designcraft-populated-stroke-redocked.png")).unwrap();
}
}
#[test]
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let destination = app.ui.docking.clone();
app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert_eq!(h.state().ui.docking, destination);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, destination);
}
#[test]
fn first_customization_preserves_the_visible_legacy_flyout() {
let mut app = app();
app.ui.open_panel = Some("paragraphStyles".into());
app.ui.dock_expanded = false;
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(layout.contains(&"paragraphStyles".into()));
assert!(valid(layout));
assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1);
}
#[test]
fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap();
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
let destination = h.state().ui.docking.clone();
let open_panel = h.state().ui.open_panel.clone();
let floating = h.state().ui.floating.clone();
// The app calls this before dock rendering when panels are hidden or Presentation starts.
let ctx = h.ctx.clone();
cancel_gesture(h.state_mut(), &ctx);
assert_eq!(h.state().ui.docking, destination);
assert_eq!(h.state().ui.open_panel, open_panel);
assert_eq!(h.state().ui.floating, floating);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, destination);
}
#[test]
fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
let undo = app.session.active().unwrap().history.undo.len();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.run("app.tablePanel", json!({})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into()));
app.run("app.tablePanel", json!({})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into()));
app.run("app.tablePanel", json!({})).unwrap();
let ctx = egui::Context::default();
let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false}));
let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") };
assert_eq!(result["ok"], true);
assert!(!app.ui.dock_expanded);
assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into()));
app.run("window.panel", json!({"panel":"properties"})).unwrap();
assert!(app.ui.dock_expanded);
assert_eq!(app.ui.docking_raise.as_deref(), Some("properties"));
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc);
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
}
#[test]
fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.run("window.panel.activate", json!({"panel":"layers"})).unwrap();
assert_eq!(app.ui.docking_raise.as_deref(), Some("layers"));
assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none());
app.run("window.workspace", json!({"name":"Typography"})).unwrap();
assert!(app.ui.docking_raise.is_none());
}
#[test]
fn shaped_panel_label_keeps_the_logical_accessibility_text() {
let ctx = egui::Context::default();
let logical = "خصائص Pages";
crate::theme::install_fonts(&ctx, "ar");
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE);
assert_eq!(galley.job.text, logical);
});
output.textures_delta.clear();
}
#[test]
fn modal_overlay_blocks_a_real_tab_drag_release() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let area = egui::Id::new("designcraft-panel-docking");
let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
assert_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture");
let before = h.state().ui.docking.clone();
h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
let destination = egui::pos2(150.0, 150.0);
h.event(Event::PointerMoved(destination));
h.run_steps(1);
h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel");
h.state_mut().ui.dialog = None;
h.run_steps(2);
assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag");
}
#[test]
fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
let mut h = crate::test_window::open(app, vec2(1440.0, 900.0));
for visible in [true, false] {
let modifiers = Modifiers { shift: true, ..Modifiers::NONE };
h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers });
h.run_steps(1);
h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible);
}
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before);
}
#[test]
fn first_story_editor_frame_conservatively_blocks_owned_tab_release() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"]
.as_u64()
.unwrap();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
let mut h = crate::test_window::open(app, vec2(1440.0, 900.0));
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()));
let from = h.ctx.read_response(tab).unwrap().rect.center();
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(from - vec2(90.0, 0.0)));
h.run_steps(2);
assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none());
let layout = h.state().app.ui.docking.clone();
let frame_before = h.ctx.cumulative_frame_nr();
h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap();
// The unmeasured first frame blocks the full content, even outside the eventual editor.
let release = egui::pos2(150.0, 150.0);
h.input_mut().events.extend([
Event::PointerMoved(release),
Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame");
let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap();
assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds");
assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle");
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc);
h.run_steps(3);
let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap();
let screen = h.ctx.content_rect();
let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)]
.into_iter()
.find(|p| !editor.contains(*p))
.expect("fixture has space outside editor");
let before_outside = h.state().app.ui.docking.clone();
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()));
let from = h.ctx.read_response(tab).unwrap().rect.center();
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(from - vec2(90.0, 0.0)));
h.run_steps(2);
assert_eq!(h.ctx.dragged_id(), Some(tab));
h.event(Event::PointerMoved(outside));
h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds");
let after = h.state().app.ui.docking.clone();
h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap();
h.run_steps(3);
assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag");
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc);
}
#[test]
fn capture_legacy_panel_menu_visual_fixture() {
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap();
app.run("app.language", json!({"lang":"ar"})).unwrap();
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark));
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Shaped legacy menu references:");
for label in ["Float Panel", "Layers"] {
crate::rtl::label(ui, crate::i18n::tr("ar", label));
}
});
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization");
let at = h.get_by_label("Properties").rect().center();
h.input_mut().events.extend([
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx));
assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible");
h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap();
let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center();
h.hover_at(group);
h.run_steps(4);
assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible");
h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap();
assert!(h.state().ui.docking.is_none());
let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap();
let logical = crate::i18n::tr("ar", last_label);
let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center();
let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap();
let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap();
let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect());
assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target");
h.input_mut().events.extend([
Event::PointerMoved(scroll_at),
Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
]);
h.run_steps(12);
let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect();
assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling");
assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling");
h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap();
h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click();
h.run_steps(4);
let layout = h.state().ui.docking.as_ref().unwrap();
assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1);
assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1);
let group = group_members_for_test(layout, last);
assert!(group.iter().any(|panel| panel == "properties"));
assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it");
assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc);
h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap();
}
+122 -7
View File
@@ -251,8 +251,9 @@ struct FontMenuState {
/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard
/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites
/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move
/// through the matches and Return picks one. Returns the family picked and closes the menu; the
/// search, Favorites and the stars keep it open (the popup closes on a click outside only).
/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog
/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the
/// stars keep it open (the popup closes on a click outside only).
pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> {
let (fonts, scope) = (fonts(app), font_scope(app));
let english = app.session.prefs.show_font_names_in_english;
@@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
if search.changed() {
st.highlight = if q.is_empty() { None } else { Some(0) };
}
let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
// The menu owns these keys while it is open: a dialog under it must not take Return as OK or
// Escape as Cancel.
let (down, up, enter, escape) = ui.input_mut(|i| {
let mut key = |k| i.consume_key(egui::Modifiers::NONE, k);
(key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape))
});
let last = shown.len().checked_sub(1);
st.highlight = match (st.highlight, last) {
(_, None) => None,
@@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
if favs_changed {
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
}
if pick.is_some() {
if pick.is_some() || escape {
ui.close();
}
pick
@@ -404,6 +410,12 @@ pub fn font_combo(
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family
/// picked.
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
// Screen readers (and tests) see the field as the combo box it looks like.
field.widget_info(|| {
let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox);
info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) });
info
});
egui::Popup::menu(field)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
@@ -506,9 +518,22 @@ mod font_menu_ui_tests {
/// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the
/// story.
fn typing() -> (Harness<'static, Window>, u64) {
typing_in("Advanced")
}
/// [`typing`] in the workspace `name`.
fn typing_in(name: &str) -> (Harness<'static, Window>, u64) {
typing_on(name, vec2(1440.0, 900.0), false)
}
/// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the
/// title bar inside the window) when `mac`.
fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.control_bar = true;
let mut h = test_window::open(app, vec2(1440.0, 900.0));
app.run("window.workspace", json!({ "name": name })).unwrap();
app.native_menu = mac;
app.integrated_titlebar = mac;
let mut h = test_window::open(app, size);
let app = &mut h.state_mut().app;
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap();
app.run("tool.select", json!({"tool": "type"})).unwrap();
@@ -523,7 +548,7 @@ mod font_menu_ui_tests {
/// The Control bar's font field, showing `family`.
fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect {
h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect()
fields(h, family)[0]
}
/// The text in the focused search field.
@@ -582,4 +607,94 @@ mod font_menu_ui_tests {
assert_eq!(story_text(&mut h, story), before);
assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected");
}
/// The font fields showing `family`, left to right and top to bottom.
fn fields(h: &Harness<'static, Window>, family: &str) -> Vec<egui::Rect> {
let mut r: Vec<egui::Rect> =
h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect();
r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y)));
r
}
/// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu
/// stays open and lists only the matches, then picks the first with ↓ and Return.
fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) {
click_at(h, at.center());
let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect());
let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused"));
click_at(h, field.center());
assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open");
h.event(egui::Event::Text("SOURCE SA".into()));
h.run_steps(4);
assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search");
assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed");
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't");
h.key_press(egui::Key::ArrowDown);
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed");
}
/// Every font field over selected text (Control bar, Contextual Task Bar, Properties ›
/// Character, Character panel) searches, in the workspaces that show them and in a small
/// window with the macOS window chrome.
#[test]
fn every_font_field_searches_in_every_workspace() {
for (name, size, mac, panel) in [
("Advanced", vec2(1440.0, 900.0), false, None),
("Printing and Proofing", vec2(1440.0, 900.0), false, None),
("Essentials", vec2(1440.0, 900.0), false, Some("character")),
("Printing and Proofing", vec2(900.0, 560.0), true, None),
] {
let (mut h, story) = typing_on(name, size, mac);
if panel.is_some() {
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
h.run_steps(4);
}
let name = &format!("{name} {size:?} {panel:?}");
let before = story_text(&mut h, story);
let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len();
assert_eq!(count, 3, "{name}: the font fields are shown");
for k in 0..count {
h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap();
h.run_steps(4);
let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k];
search_in_menu(&mut h, at, &format!("{name}, field {k}"));
assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}");
assert_eq!(story_text(&mut h, story), before, "{name}, field {k}");
}
}
}
/// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic
/// Character Formats and Find/Replace Font's Replace With.
#[test]
fn font_fields_in_panels_and_dialogs_search() {
let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"});
for (what, panel, dialog, shown) in [
("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY),
("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY),
("Find/Replace Font", None, Some(("findFont", json!({}))), "—"),
] {
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mut h = test_window::open(app, vec2(1440.0, 900.0));
let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string());
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields));
h.run_steps(4);
let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown"));
search_in_menu(&mut h, at, what);
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open");
assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked");
// Escape closes the menu only.
let at = fields(&h, "Source Sans 3")[0];
click_at(&mut h, at.center());
assert!(h.ctx.text_edit_focused(), "{what}: the menu opened");
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu");
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open");
}
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict.
use craft_ui::docking::{Layout, Location};
use serde_json::Value;
use crate::UiState;
impl UiState {
/// Read saved preferences, discarding only unreadable optional docking metadata.
/// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces.
pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result<Self> {
let mut saved: Value = serde_json::from_slice(bytes)?;
recover_docking(&mut saved);
if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) {
for workspace in workspaces {
recover_docking(workspace);
}
}
serde_json::from_value(saved)
}
}
fn recover_docking(saved: &mut Value) {
let Some(fields) = saved.as_object_mut() else { return };
if fields.get("docking").is_some_and(|value| serde_json::from_value::<Option<Layout<String>>>(value.clone()).is_err()) {
fields.remove("docking");
}
if let Some(hidden) = fields.get_mut("dockingHidden") {
if let Some(entries) = hidden.as_object_mut() {
entries.retain(|_, value| serde_json::from_value::<Location<String>>(value.clone()).is_ok());
} else {
fields.remove("dockingHidden");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DesignApp, SavedWorkspace};
use serde_json::json;
fn populated() -> UiState {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
app.ui.ui_scale = 1.5;
app.ui.language = "ja".into();
app.ui.custom_workspaces = vec![
SavedWorkspace {
name: "Print".into(),
docking: app.ui.docking.clone(),
docking_hidden: app.ui.docking_hidden.clone(),
..Default::default()
},
SavedWorkspace { name: "Proof".into(), docking: app.ui.docking.clone(), ..Default::default() },
];
app.ui
}
#[test]
fn saved_preferences_preserve_valid_layouts_and_hidden_origins() {
let original = serde_json::to_value(populated()).unwrap();
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(loaded).unwrap(), original);
}
#[test]
fn future_layouts_recover_independently_without_resetting_preferences_or_workspaces() {
let original = populated();
for field in ["docking", "dockingHidden"] {
for invalid in [json!({"root":{"FutureLayout":{}}}), json!(false)] {
let mut saved = serde_json::to_value(&original).unwrap();
saved[field] = invalid.clone();
saved["customWorkspaces"][0][field] = invalid;
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "command parsing remains strict");
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert_eq!(loaded.ui_scale, 1.5);
assert_eq!(loaded.language, "ja");
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Print");
assert_eq!(loaded.custom_workspaces[1].name, "Proof");
assert_eq!(loaded.custom_workspaces[1].docking, original.custom_workspaces[1].docking);
if field == "docking" {
assert!(loaded.docking.is_none());
assert!(loaded.custom_workspaces[0].docking.is_none());
assert_eq!(loaded.docking_hidden, original.docking_hidden);
assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden);
} else {
assert!(loaded.docking_hidden.is_empty());
assert!(loaded.custom_workspaces[0].docking_hidden.is_empty());
assert_eq!(loaded.docking, original.docking);
assert_eq!(loaded.custom_workspaces[0].docking, original.custom_workspaces[0].docking);
}
}
}
}
#[test]
fn unreadable_hidden_origin_does_not_discard_other_origins() {
let original = populated();
let mut saved = serde_json::to_value(&original).unwrap();
saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}});
saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false);
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(!original.docking_hidden.is_empty());
assert_eq!(loaded.docking_hidden, original.docking_hidden);
assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden);
}
#[test]
fn unrelated_malformed_preferences_are_still_rejected() {
assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err());
assert!(UiState::from_preferences_json(br#"{"customWorkspaces":false}"#).is_err());
}
}
+66 -7
View File
@@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip";
/// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside
/// `canvas`.
pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
let grip_id = egui::Id::new(GRIP);
let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane));
// (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.)
let dragging = ctx.dragged_id() == Some(grip_id);
if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) {
@@ -28,7 +28,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
return;
}
let t = Tokens::get(ctx);
let id = egui::Id::new("task_bar");
// The same selection can be shown in two canvases; each bar needs its own area and widgets.
let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane));
// Width from the previous frame (content-sized).
let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0);
let at = match app.ui.task_bar_pin {
@@ -46,7 +47,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
pos2(x, y)
}
};
app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]);
// A shared persisted pin is relative to each canvas. The transient pin target belongs
// to the focused pane, so rendering the sibling cannot overwrite it.
if app.pane == app.focus_pane {
app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]);
}
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| {
egui::Frame::NONE
.fill(t.panel)
@@ -75,7 +80,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
} else {
object_controls(app, ui);
}
more(app, ui);
more(app, ui, [at.x - canvas.min.x, at.y - canvas.min.y]);
});
});
});
@@ -266,7 +271,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) {
}
}
fn more(app: &mut DesignApp, ui: &mut Ui) {
fn more(app: &mut DesignApp, ui: &mut Ui, at: [f32; 2]) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click());
if resp.hovered() {
@@ -286,7 +291,8 @@ fn more(app: &mut DesignApp, ui: &mut Ui) {
let pinned = app.ui.task_bar_pin.is_some();
let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position");
if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() {
let _ = app.run("window.taskBarPin", json!({"on": !pinned}));
let params = if pinned { json!({"on": false}) } else { json!({"on": true, "at": at}) };
let _ = app.run("window.taskBarPin", params);
ui.close();
}
if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() {
@@ -323,7 +329,7 @@ mod tests {
}
fn bar(h: &Harness<'static, Window>) -> egui::Rect {
h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap()
h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap()
}
fn grip(h: &Harness<'static, Window>) -> egui::Pos2 {
@@ -372,6 +378,59 @@ mod tests {
assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0]));
}
#[test]
fn split_pane_grips_handle_only_their_own_drag() {
let (mut h, _) = window();
h.set_size(vec2(2200.0, 900.0));
// A compact object HUD fits in each pane, leaving room to exercise both axes
// without the intentional canvas-edge clamp masking horizontal movement.
let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap();
h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap();
h.state_mut().app.run("window.split", json!({"on":true})).unwrap();
h.run_steps(6);
let area =
|h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap();
for pane in [0_u8, 1] {
h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap();
h.run_steps(4);
let before = area(&h, pane);
let from = pos2(before.min.x + 10.0, before.center().y);
let delta = vec2(24.0, 28.0);
drag(&mut h, from, delta);
let after = area(&h, pane);
assert!((after.min - before.min - delta).length() < 2.0, "pane {pane} drag was handled by a sibling grip: {before:?} -> {after:?}");
assert!(h.state().app.ui.task_bar_pin.is_some());
}
}
#[test]
fn popup_pin_uses_the_invoking_hud_position_after_focus_moves_to_sibling() {
let (mut h, _) = window();
let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap();
h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap();
h.state_mut().app.run("window.split", json!({"on":true})).unwrap();
h.run_steps(6);
h.state_mut().app.switch_pane(1);
h.state_mut().app.run("view.zoom", json!({"zoom":0.5})).unwrap();
h.state_mut().app.switch_pane(0);
h.run_steps(4);
let expected = h.state().app.ui.task_bar_at.unwrap();
let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap();
click_at(&mut h, pos2(left.max.x - 19.0, left.center().y));
let pin = h.get_by_label_contains("Pin Bar Position").rect();
let at = pos2(pin.max.x - 2.0, pin.center().y);
let sibling = h.state().app.other_pane.as_ref().and_then(|(_, rect)| *rect).unwrap();
assert!(sibling.contains(at), "fixture popup must extend into sibling canvas: {at:?} outside {sibling:?}");
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
assert_eq!(h.state().app.focus_pane, 1);
assert_ne!(h.state().app.ui.task_bar_at, Some(expected), "fixture HUD positions must differ");
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(4);
assert_eq!(h.state().app.ui.task_bar_pin, Some(expected), "Pin must use the invoking HUD position");
}
#[test]
fn the_grip_moves_the_bar_and_it_stays_there() {
let (mut h, id) = window();
+5 -12
View File
@@ -332,18 +332,11 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
v.selection.stroke = Stroke::new(1.0, t.text_strong);
v.hyperlink_color = t.accent;
v.override_text_color = Some(t.text);
for (w, fill) in [
(&mut v.widgets.noninteractive, t.panel),
(&mut v.widgets.inactive, t.button),
(&mut v.widgets.hovered, t.hover),
(&mut v.widgets.active, t.tool_active),
(&mut v.widgets.open, t.hover),
] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.corner_radius = r;
w.fg_stroke = Stroke::new(1.0, t.text);
}
craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to(
&mut v.widgets,
r,
Stroke::new(1.0, t.text),
);
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border);
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border);
+9
View File
@@ -1,5 +1,7 @@
# Arabic import and composition
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026
Task: AR1. These changes repair verified gaps in the existing typesetting pipeline.
They do not claim full InDesign or vendor-specific Naskh composition parity.
@@ -74,3 +76,10 @@ compatibility warnings remain, along with separate font/link/overset findings.
- [Diacritic position enumeration](https://developer.adobe.com/indesign/uxp/dom/api/d/diacritic-position-options/)
and [positional forms](https://developer.adobe.com/indesign/uxp/dom/api/p/positional-forms/)
identify serialized options; their presence alone does not establish visual parity.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) |
| 2026-10-07 | major | Created: AR1: Arabic import and composition |
+98
View File
@@ -0,0 +1,98 @@
# DesignCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026
How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
rules (never crash, everything is a command, layering, clean-room).
## Workspace
A Cargo workspace: 16 library crates in `crates/`, 3 apps in `apps/`, and `xtask`. 126,411
lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default,
`unsafe_code = "forbid"` workspace-wide.
| Layer | Crate | What it does | Lines |
|---|---|---|---|
| L0 | `geom` | kurbo re-export, units and measurement parsing (`1p6`, `12mm`, `3in+2p`), corner shapes, polygons, transforms, hit testing | 2.5k |
| L0 | `color` | CMYK/RGB/Gray (Lab in CMS), swatches, tints, gradients, blend modes, ASE, ICC colour management (`cms/`), colour harmony | 2.9k |
| L1 | `doc` | the document model: spreads, pages, parents, layers, items, stories (text + run lists), tables, notes, styles of five kinds, swatches, sections, variables, CJK and Arabic attributes | 10.2k |
| L1 | `fonts` | font database (bundled OFL fonts, system fonts, document fonts, optional craft-fonts), metrics, outlines (skrifa), shaping (harfrust), variable instances, font classification for menus | 3.5k |
| L2 | `compose` | the text engine: style resolution, shaping, Knuth–Plass and single-line breaking, Liang hyphenation, justification, tabs, keeps, columns, span/split, baseline grid, vertical justification, wrap, threading, overset, anchored objects, footnotes, tables, bidi, CJK (ruby, warichu, kinsoku), caret and hit testing | 11.7k |
| L2 | `images` | decoding placed graphics (PNG, JPEG, GIF, WebP, TIFF incl. CMYK, BMP, PSD composite, EPS preview) | 1.6k |
| L3 | `render` | document to pixels with vello_cpu (SIMD, multithreaded): items, composed text, images with mips, gradients, effects, damage regions, placed PDF via hayro | 3.5k |
| L3 | `pdf` | PDF export with krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k |
| L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k |
| L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k |
| L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k |
| L3 | `format` | the native `.designcraft` zip (`document.json`, `assets/`, `meta.json`), versioned | 0.3k |
| L4 | `tools` | tool state machines: pointer events and modifiers to commands and overlays; snapping and smart guides | 5.9k |
| L5 | `engine` | `Session`: documents, history, selection, the command registry (446 commands in `cmd/`), compose cache, preflight, find/change, scripts, recovery, links | 29.3k |
| L6 | `ui-egui` | the InDesign-style egui frontend: menus, Control panel, Tools, dock and panels, dialogs, canvas, theme tokens, i18n, the control channel | 33.4k |
| L6 | `mcp` | MCP server over stdio, headless or proxying to the running app | 1.7k |
| L7 | `apps/designcraft` | desktop binary (eframe/wgpu), file dialogs, control server | 1.6k |
| L7 | `apps/designcraft-cli` | headless CLI: `run`, `script`, `app`, `describe`, `commands`, `mcp` | 1.2k |
| L7 | `apps/designcraft-web` | the same UI in WASM (WebGPU, WebGL2 fallback) | 0.3k |
`cargo xtask layers` enforces the layering: a crate depends only on lower layers (with the
intra-layer orders geom → color and doc → fonts); nothing below L6 depends on egui, eframe, winit
or rfd; L0–L6 compile for `wasm32-unknown-unknown` (`cargo xtask wasm`).
## Data model
Coordinates are points (1/72 in), y down; each spread has its own coordinate system with its
origin at the top left of its first page. A `Document` holds settings, spreads and parent
spreads (items under `Arc`, so edits are copy-on-write and undo is cheap), layers, stories,
styles (paragraph, character, object, table, cell; based-on chains and groups), swatches,
sections and text variables. A story is one `String` plus paragraph and character run lists of
`{len, style, overrides}`; effective attributes are default ← based-on chain ← style ← overrides.
Special characters (page number, footnote reference, anchored object, table) are placeholder
code points in the text. Threading is the story's ordered frame list.
## Commands, the only way to change a document
Every user-visible action is a `CommandSpec` (`id`, `label`, menu path, shortcut, parameter
doc, `enabled`, `run`, journaled, undoable) in `crates/engine/src/cmd/*`. The UI, the CLI, MCP
and the control channel all call `Session::execute(id, params)`. UI-only commands (windows,
panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way.
Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled
command is replayable as a script. Escaped panics during a command or an import/export become an
error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery
folder every 30 s.
## Composition and rendering
The compose cache is keyed by story revision, frame geometry, style revision and font database
revision; stories compose in parallel (rayon) on native targets. Composition produces per-frame
lines with positioned glyph runs, decorations, anchors and overset. The canvas renders on a
background worker with vello_cpu into tiles; after an edit only frames whose composition changed
are re-rendered and patched (damage regions, pixel-identical to a full render). The GPU (wgpu:
Metal, DirectX 12, Vulkan, OpenGL; WebGPU/WebGL2 on the web) only presents; the backend is chosen
before the window exists, with a start-up fallback list in `gpu.json`.
## File I/O
Native `.designcraft` (zip + JSON); IDML both ways; PDF, EPUB, HTML, PNG/JPEG, text, XML export;
placed graphics through `images` (PDF/AI pages through hayro); Word/RTF/Excel/Tagged Text through
`textimport`. INDD is not readable. Details: [file-format-parity.md](file-format-parity.md).
## Agent control
The desktop app opens a loopback JSON-lines control channel (`--control PORT`) with
`engine.execute`, inspection, real input injection (`ui.pointer`, `ui.key`, `ui.click`, `ui.drag`),
dialog control, screenshots and offscreen renders. `designcraft-cli mcp` exposes the same as MCP
tools, headless or connected to the app. See [agents.md](agents.md), [mcp.md](mcp.md),
[control-protocol.md](control-protocol.md).
## Quality gates
`cargo xtask ci`: rustfmt, clippy `-D warnings`, tests, `assets` (every asset attributed in
ASSETS.md), `layers`, `wasm`. Clean crates deny `unwrap`/`expect`/`panic!`/`todo!`/
`unimplemented!`/`unreachable!`. CI on GitHub currently runs only the FreeBSD and Windows ARM64
builds (on manifest changes), packaging lint and the release workflow; `xtask ci` runs locally.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
+9
View File
@@ -1,5 +1,7 @@
# CJK import and composition
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026
Task: CJK1. This is incremental support, not a claim of InDesign composition parity.
## Data flow
@@ -62,3 +64,10 @@ removing unsupported-rule warnings to produce a clean report is not acceptable.
Tests use synthetic documents; user IDML files and proprietary application output
are not committed as fixtures.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) |
| 2026-10-07 | major | Created: CJK1: IDML CJK import and composition |
+10
View File
@@ -1,5 +1,7 @@
# CJK typesetting plan
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026
Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian
feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a
DesignCraft document can be set to the standard of a Japanese, Chinese or Korean publisher,
@@ -285,3 +287,11 @@ Each follows what an InDesign user expects.
Chinese, since the exact names InDesign writes are unconfirmed without a sample file.
8. **Missing glyphs** are drawn as the font's box and reported, as in InDesign; drawing them from
fallback fonts is a per-document choice, on only for documents saved before it existed.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) |
| 2026-10-10 | minor | Updated (see git history) |
| 2026-10-06 | major | Created: CJK typesetting plan and status (the Japanese/CJK feature set) |
+48
View File
@@ -3,6 +3,17 @@
Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on
`127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line.
For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before
launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset
override preserves the usual platform paths; an explicitly empty override disables those paths.
`DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery.
Control pointer and key input is queued across native frames. After a drag, wait for both the
expected document state and `document.inspect.canUndo` before testing undo; an object can appear
in the preview before its undo entry is committed. On macOS, default shortcuts owned by the native
menu (including Command-Z) are skipped by the egui shortcut handler. `ui.key` does not send a Cocoa
menu event; use `ui.menu.invoke` for a menu-command test and test physical accelerators separately.
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params`
are optional; blank lines are skipped). Anything else gets one error reply
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so
@@ -44,3 +55,40 @@ merged document; the template stays as it was ([agents.md](agents.md#data-merge)
Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu).
The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents.
## Shared panel docking
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
and movable, resizable floating groups inside the application window. Drag a panel tab,
or use its context menu. Floating groups remain within the application viewport.
The shared layout begins with the existing workspace on the first docking operation.
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
positions of hidden panels. Resetting a built-in workspace restores its default dock.
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
tabs by panel ID. The destination must already be visible.
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
dock placement when possible, or to the supplied destination.
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
position when possible. `window.panel.close {panel}` hides it.
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
invalid destinations return an error without changing the arrangement.
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
resizes the root divider (`false`/`true` path entries select first/second children).
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
split paths return an error without changing the workspace.
After panel customization, `app.tablePanel` (including Shift+F9) still toggles the
Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel;
`dockExpanded:false` hides the root dock while floating groups remain visible.
The icon rail can reopen a collapsed dock through any root panel. Activating a
floating panel also raises its group. These operations change only UI layout.
+110
View File
@@ -0,0 +1,110 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
`UTExportedTypeDeclarations`), its import/export filter folder names and its File menus; ours from
the open/place/export filters in `apps/designcraft/src/main.rs` and the `file.*` commands.
Overall numbers: [target-app-parity.md](target-app-parity.md); open problems: [gaps.md](gaps.md).
**Dimension score: ~40% ready (estimated), 150–250 h + 20–60 h for INDD integration.** The
single largest gap is that `.indd` can't be opened.
Read/Write: **✓** supported · **~** partial · **✗** missing · **—** InDesign doesn't either.
Weight: what share of an InDesign professional's file traffic the format carries (used for the
score below).
## Documents and interchange
| Format | Ext | InDesign | DesignCraft read | DesignCraft write | Fidelity / tests | Weight |
|---|---|---|---|---|---|---|
| InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% |
| InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% |
| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% |
| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — |
| InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% |
| InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% |
| Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% |
| InCopy story / assignment | `.icml`, `.icma`, `.incx`, `.inca` | read/write | ✗ | ✗ | InCopy workflow out of scope; ICML story import would still help | 1% |
| InDesign Interchange (legacy) | `.inx` | read | ✗ | — | | <1% |
| PageMaker | `.pmd`, `.p65`, `.t65`, `.pmt` | read | ✗ | — | | <1% |
| QuarkXPress | `.qxd`, `.qxt` | read (old versions) | ✗ (#73) | — | | <1% |
## Output
| Format | Ext | InDesign | DesignCraft | Fidelity / tests | Weight |
|---|---|---|---|---|---|
| PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF | 53 tests in `crates/pdf`; no PDF/X-1a or X-3 (needs flattening to PDF 1.3/1.4); no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% |
| PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% |
| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% |
| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, CSS from styles, images, nav | | 2% |
| EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% |
| HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% |
| HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% |
| SVG export | `.svg` | ✓ (via plug-in) | ✗ | | <1% |
| EPS export | `.eps` | ✓ | ✗ | | <1% |
| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% |
| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% |
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
## Placed and imported content
| Format | Ext | InDesign | DesignCraft | Notes | Weight |
|---|---|---|---|---|---|
| PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% |
| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% |
| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% |
| PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% |
| EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% |
| SVG | `.svg` | ✓ | ✓ (usvg) | | <1% |
| HEIC | `.heic` | ✓ | ✗ | | <1% |
| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% |
| RTF | `.rtf` | ✓ | ✓ | | <1% |
| Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% |
| Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% |
| InDesign Tagged Text | `.txt` | ✓ | ✓ import and export | | <1% |
| CSV / TSV (Data Merge) | | ✓ | ✓ (+ Excel) | | <1% |
| Audio / video | `.mp4`, `.mp3`, … | ✓ placeholders for EPUB/PDF | ~ placed as media objects | no in-app playback | <1% |
## Resources and settings
| Format | Ext | InDesign | DesignCraft |
|---|---|---|---|
| Adobe Swatch Exchange | `.ase` | read/write | ✓ load and save |
| Colour books | `.acb` | read (17 libraries ship) | ✗ (licensed libraries; Pantone etc. out of scope, own libraries possible) |
| ICC profiles | `.icc`, `.icm` | read | ✓ |
| Colour settings | `.csf` | read/write | ✗ |
| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ |
| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ |
| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) |
| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) |
| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) |
| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) |
## Score
Weighted by the Weight column (documents, output and placed tables; resources unweighted),
counting ✓ = 1, ~ = 0.5, and IDML at 0.6 until a real-file corpus proves it: **~45% present**.
Taking fidelity into account (no INDD, IDML unproven, PDF/X-1a and presets missing), **~40% ready
for real work** (estimated). INDD alone moves this dimension by up to 30 points.
## Remaining effort
| Work | Opus 5.5 h |
|---|---|
| INDD import via a clean-room converter (owner decision, #114) | 20–60 |
| IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 |
| PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 |
| Print fidelity, PostScript level 3 output and print presets | 25–45 |
| ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 |
| HTML / fixed-layout EPUB with live text | 20–35 |
| JPEG export dialog, SVG export, EPS export | 10–20 |
| **Total** | **190–355** (150–250 excluding INDD and PostScript output) |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code |
+228
View File
@@ -0,0 +1,228 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
Numbers and weights are in [target-app-parity.md](target-app-parity.md); the plan is in
[roadmap.md](roadmap.md). Estimates are Opus 5.5 agent-hours, calibrated in
target-app-parity.md. When you close a gap, delete its entry (or shrink it), add a line to the
ROADMAP progress log and update the timestamps.
Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · **H** hardware ·
**L** localization · **P** performance · **S** stability · **E** ecosystem · **A** AI.
## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate))
### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER**
- **Missing:** threading a story from one frame to another. The canvas draws in and out ports
(`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text
cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking
a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP
(`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the
sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which
users report adding pages they can't delete (#165).
- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's
missing everywhere).
- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next
that the designer drew. That is InDesign's core text workflow.
- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and
click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7).
- **Doc:** [ui-parity.md](ui-parity.md)
## Blocking (beta can't be declared while these are open)
### 1. Basic editing fails for many users (U, S)
- **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF
paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't
open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields
(#189); character formatting set at a caret is discarded (#289).
- **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds.
- **Impact:** a layout artist can't finish the simplest job. This dominates the
ready-for-real-work number.
- **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests).
- **Doc:** [ui-parity.md](ui-parity.md)
### 2. InDesign documents (.indd) can't be opened (FF)
- **Missing:** INDD (and INDT, INDB, INDL) reading. Everything InDesign users receive is INDD.
- **Evidence:** #21. Two clean-room converters are proposed: PR #98 and `indd-utils` (#114),
which reports 96.9% of InDesign's IDML values reproduced on ~650 INDD/IDML pairs, 0 failures on
~4,300 files, and output that DesignCraft's IDML importer already reads.
- **Impact:** without it, every job starts with "ask the client for an IDML".
- **Estimate:** 20–60 h to integrate indd-utils (INDD → IDML → existing importer), warnings UI,
corpus test; **owner decision** on provenance and where the crate lives.
- **Doc:** [file-format-parity.md](file-format-parity.md)
### 3. IDML round-trip isn't proven on real files (FF)
- **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or
a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not
rendered without a warning (#193), `$ID/` font names reported missing (#338), no
`PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop
open of IDML on the app icon (#211).
- **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic,
verified in InDesign 2026 with synthetic files), but nothing is measured on real documents.
- **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes
back to InDesign.
- **Estimate:** 60–100 h (corpus, metric, fixes).
- **Doc:** [file-format-parity.md](file-format-parity.md)
### 4. Composition isn't compared with InDesign (T)
- **Missing:** an oracle harness: the same synthetic stories set by InDesign 2026 (observed
black-box, results kept under `plan/`) and by DesignCraft, comparing line breaks, overset and
frame fit. Known differences: optical kerning falls back to metrics, so imported text sets
wider (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline
(#180); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work"
(#87).
- **Impact:** an opened IDML reflows: different line breaks, different page count, overset text.
Professionals notice on the first page.
- **Estimate:** 80–140 h (harness 20–30, optical kerning 15–25, then fixes by cluster).
- **Doc:** [typography-parity.md](typography-parity.md)
### 5. Startup crashes on some graphics hardware (S, H)
- **Missing:** a start that survives every GPU/driver: Metal device lost on Intel Macs (#334),
AMD OpenGL driver crash on Windows (#167), launch failures (#273, #220, #192, #166), blank or
frozen canvas after graphics loss (#90).
- **Evidence:** a backend fallback landed 2026-10-10 (#297: DirectX 12 on Windows, `gpu.json`
skip list); not yet confirmed by the reporters.
- **Estimate:** 15–30 h.
- **Doc:** [hardware-parity.md](hardware-parity.md)
### 6. Installed fonts not always found (F)
- **Missing:** fonts in user font folders used for text (#161, #110, #107), fonts activated by
font managers and Adobe Fonts (#327, #261), reload when fonts change on disk (#328).
- **Impact:** documents set in the client's fonts render in Source Sans 3: wrong everywhere.
- **Estimate:** 10–20 h (VectorCraft has a port, #261).
- **Doc:** [typography-parity.md](typography-parity.md)
### 7. No CI runs the tests on pull requests (S)
- **Missing:** a PR workflow running `cargo xtask ci` (fmt, clippy, tests, layers, wasm). Today
only FreeBSD and Windows ARM build checks run, and only when manifests change; `main` has no
branch protection. 65 PRs are open, some integrated in bulk (#259).
- **Impact:** regressions land unseen; the 1,122 tests protect nothing they aren't run on.
- **Estimate:** 4–8 h (plus runner minutes; owner decision on required checks).
## High
### 8. Canvas text input has no IME (U, L)
- **Missing:** composition input for Chinese, Japanese and Korean on the canvas (#322).
VectorCraft has an implementation to port.
- **Estimate:** 10–20 h. **Doc:** [localization-parity.md](localization-parity.md)
### 9. PDF output presets and standards (FF)
- **Missing:** PDF/X-1a and PDF/X-3 (no flattener to PDF 1.3/1.4), Adobe PDF Presets and
`.joboptions` load/save, output-intent choice beyond the built-in profile, PDF/UA validation;
#159, #32.
- **Have:** PDF/X-4 with output intent and a validator (`#200`), PDF/A-2b, tagged PDF, CMYK and
spot, marks and bleed, the options dialog with remembered settings (#196).
- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md)
### 10. Printing (H, F)
- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping.
- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through
craft-print: CUPS on macOS/Linux and the Windows PDF raster spooler. Physical printer fidelity
remains a hardware check.
- **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md)
### 11. Paragraph and character style editing reliability (F)
- **Missing:** saving edits and deleting a paragraph style reported failing (#169); Tabs panel
for setting tab stops (#326).
- **Estimate:** 10–20 h. **Doc:** [ui-parity.md](ui-parity.md)
### 12. Tables in depth (F)
- **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options
submenus as InDesign lays them out, Diagonal Lines, Convert Rows to Header/Body/Footer, Edit
Header/Footer, Go to Row; general reliability (#20).
- **Estimate:** 25–40 h.
### 13. Hyphenation and spelling languages (T, L)
- **Missing:** hyphenation patterns for every language but English and Spanish; spelling for
every language but English. InDesign ships dictionaries for ~40 languages.
- **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need
licence review). **Doc:** [typography-parity.md](typography-parity.md)
### 14. Windows and Linux are thinly tested (S)
- **Missing:** runtime testing on Windows and Linux: installer confusion (#64), pull-down panels
can't scroll on Linux Mint (#267), tab bar overlaps menus in some workspaces (#182), CJK UI
font missing on Linux (#332).
- **Estimate:** 30–60 h (smoke tests in CI, fixes).
## Medium
### 15. Long documents in depth (F)
- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide
pagination, book PDF export, book TOC and index; Table of Contents Styles.
- **Estimate:** 30–50 h.
### 16. Menu items still missing (U)
- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference
update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear
Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward,
Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026),
View ▸ Extras badges and Story Editor options.
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
- **Doc:** [ui-parity.md](ui-parity.md)
### 17. HTML and fixed-layout EPUB (FF)
- **Missing:** HTML export is one basic page (#314); fixed-layout EPUB writes page images, not
live text and CSS; no animation or timing for EPUB/HTML.
- **Estimate:** 30–50 h.
### 18. Colour management depth (F)
- **Missing:** Assign Profiles / Convert to Profile, per-image rendering intents in output,
hex entry and colour picker issues (#323, #190), keep kerning on colour change (#29).
- **Estimate:** 20–35 h.
### 19. Placed graphics depth (FF)
- **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer
comps beyond visibility; Edit Original / Edit With; PDF placed as editable vectors (#227, #24);
QuarkXPress import (#73).
- **Estimate:** 30–60 h (PostScript interpreter is a large separate project: +80–150 h).
### 20. Performance on large documents (P)
- **Missing:** incremental composition (recompose from the edited paragraph; 8 ms keystroke
budget), benchmarks against InDesign on long books and image-heavy catalogues.
- **Have:** compose 300k characters ~38 ms warm, damage-region repaint (< 1 ms while typing),
parallel story composition.
- **Estimate:** 40–80 h.
## Lower
### 21. Localization breadth (L)
- **Missing:** Hindi, Indonesian, Korean, Vietnamese interfaces; ~17% of strings in the 83–85%
languages; 39% of pt-BR; native-speaker review for all. **Estimate:** 120–180 h.
**Doc:** [localization-parity.md](localization-parity.md)
### 22. InDesign scripting compatibility (E)
- **Missing:** an ExtendScript / UXP-compatible DOM so existing InDesign scripts run. Our own
command API, scripts panel, CLI and MCP are strong and agent-friendly, but not compatible.
- **Estimate:** 150–250 h for a useful DOM subset; **owner decision**.
### 23. Plug-ins (E)
- **Missing:** any plug-in model. InDesign's third-party ecosystem (imposition, preflight, data
publishing) doesn't carry over and can't. **Owner decision**; not estimated.
### 24. AI features (A)
- **Missing:** InDesign 2026's Generative Expand, Text to Image and Auto Style equivalents; local
generative vector requested (#232). **Owner decision** on openly licensed models.
- **Estimate:** 60–120 h.
### 25. Accessibility of the app itself (U)
- **Missing:** screen-reader exposure of panels and dialogs (egui AccessKit coverage unverified);
PDF/UA validation of exports. **Estimate:** 15–25 h.
## Out of scope (by design)
Adobe cloud services (CC Libraries, Stock, Adobe Fonts activation, Publish Online, Share for
Review, Creative Cloud sign-in), InCopy assignment workflows, Bridge, Exchange extensions, Version
Cue. These are excluded from every percentage.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) |
| 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 |
+60
View File
@@ -0,0 +1,60 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
What InDesign does with hardware, per platform, against DesignCraft. Layout is not a
GPU-hungry workload, so this dimension carries little weight, but a start that fails on a
graphics driver loses the user entirely. Overall numbers: [target-app-parity.md](target-app-parity.md).
**Hardware: ~45% ready, 20–40 h. Platforms: ~55% ready, 40–80 h.** (estimated)
## Hardware features
| Feature | InDesign 2026 | DesignCraft macOS | Windows | Linux | Web |
|---|---|---|---|---|---|
| GPU display (canvas) | GPU Performance: GPU preview, animated zoom (Metal on macOS; Windows with supported GPUs) | canvas rasterized on the CPU (vello_cpu, SIMD, multithreaded), presented by wgpu (Metal) | wgpu DirectX 12 (since #297), Vulkan/OpenGL fallback | Vulkan, OpenGL fallback | WebGPU, WebGL2 fallback |
| Start-up on any GPU | robust | Metal device lost on Intel Macs (#334) | AMD OpenGL crash (#167), launch failures (#273) | some reports (#192, #220) | |
| Backend fallback | n/a | `gpu.json` skips a backend whose start never showed a frame (#297) | same | same | |
| Animated / scrubby zoom | ✓ (GPU) | ✓ (CPU tiles, damage regions) | ✓ | ✓ | ✓ |
| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ |
| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ |
| Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ |
| Printing | system print with PPDs, PostScript, separations | PDF to CUPS (`craft-print`) | Windows PDF raster spooler (`craft-print`) | PDF to CUPS (`craft-print`) | browser download |
| Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ |
| Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ |
| Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded |
## Platforms
| Platform | InDesign 2026 | DesignCraft | Evidence |
|---|---|---|---|
| macOS (Apple silicon, Intel) | ✓ | ✓ primary development platform | Intel GPU start failure (#334); window controls misaligned (#333); font managers (#327) |
| Windows x64 | ✓ | ~ builds and installers ship | copy/paste and context menus (#351, #312), installer confusion (#64), launch failures (#273, #167) |
| Windows ARM64 | ✓ | ~ build (CI workflow) | not exercised at runtime |
| Linux (x86_64, aarch64; riscv64 tar.gz) | ✗ | ~ deb, AppImage, Flatpak | clipboard (#164), panels don't scroll (#267), CJK UI font (#332) |
| FreeBSD | ✗ | ~ build | |
| Web (WASM) | ✗ (InDesign on the web is not shipped) | ~ same UI, file picker, downloads; no control channel | |
DesignCraft runs on more platforms than InDesign; what's missing is runtime testing on the ones
the developers don't use daily.
## Remaining effort
| Work | Opus 5.5 h |
|---|---|
| Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 |
| Printing fidelity, PostScript and print presets | 15–30 (counted in file-format-parity.md) |
| Monitor profile in display colour management | 5–10 |
| Windows and Linux runtime smoke tests in CI | 20–40 |
| Multi-window document views | 15–30 |
| **Hardware + platforms** | **60–120** |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker |
Windows submission now uses shared craft-print. It selects matching or default driver paper,
shrinks oversized sheets to the printable area without enlarging smaller ones, and preserves
the queue's duplex default; printer-driver options UI and physical output validation remain open.
+80
View File
@@ -0,0 +1,80 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6)
Per-language status of the DesignCraft interface and of document typesetting in each script.
Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md),
[arabic-typography.md](arabic-typography.md). Overall numbers: [target-app-parity.md](target-app-parity.md).
**Dimension score: ~45% ready (estimated), 120–180 h** plus native-speaker review (human).
## How it was measured
Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French,
Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`.
Untranslated strings fall back to English. A script (2026-10-10) built the set of English
interface strings as the union of every catalog's keys, every engine command label and every
literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals
in the UI crate appear in no catalog (some are test data or proper names), so the true total is
somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no
help content in any language (Help ▸ opens the project's pages).
InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es,
fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew
come as Middle East editions with an English or French interface and right-to-left features.
## Key languages
| Language | Code | InDesign 2026 UI | UI strings translated | Dialogs / tooltips / help | Script support (documents and UI) | Native review | Status | To `full` (h) |
|---|---|---|---|---|---|---|---|---|
| English | en | ✓ | 1,724 / 1,724 (100%) | all / all / none | Latin ✓ | yes | full | 0 |
| Simplified Chinese (Mandarin) | zh | ✓ (zh_CN) | 1,438 (83%) | menus, panels, most dialogs; tooltips partly; no help | CJK typesetting ✓ (cjk.md); **no canvas IME** (#322); CJK UI font not embedded in release builds, so boxes on Linux (#332) | no | partial | 25–40 |
| Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 |
| Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 |
| Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 |
| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 |
| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; no Portuguese hyphenation | no | partial | 8–12 |
| Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 |
| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) |
| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 |
| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; **no IME** (#322) | no | none | 15–25 |
| Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 |
Percentages are of the 1,724 measured strings. A language is `full` only when every user-visible
string is translated and its script renders and edits correctly; none but English is.
## Other shipped languages
| Language | Code | UI strings translated | Status |
|---|---|---|---|
| Italian | it | 1,464 (85%) | partial |
| Ukrainian | uk | 1,432 (83%), plural categories handled | partial |
DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its
eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW).
## Document languages
InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries).
DesignCraft hyphenates **English and Spanish** and spell-checks **English** only. Text language
can be set per paragraph and character (Properties, Character panel) and drives quotes and
locale shaping. See [typography-parity.md](typography-parity.md).
## Remaining effort
| Work | Opus 5.5 h |
|---|---|
| Fill the 83–85% catalogs (de, fr, es, zh, ar, it, uk; ~290 strings each) | 25–40 |
| pt-BR to full | 8–12 |
| New: Hindi, Indonesian, Korean, Vietnamese (1,724 strings, plural rules, tests) | 50–75 |
| Canvas IME (zh, ja, ko) | 10–20 |
| CJK UI font in every release build; Devanagari and Vietnamese rendering tests | 10–15 |
| Catalog completeness test: every `tr` key and command label present in every catalog | 4–8 |
| Help content (at least English, then the twelve) | 15–25 |
| **Total** | **120–180** + native-speaker review for 11 languages (human) |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian |
+2 -1
View File
@@ -51,7 +51,8 @@ claude mcp add designcraft -- designcraft-cli mcp
| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work |
In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so
what the agent does shows up live in the window. The connection is re-established once if the app restarts.
what the agent does shows up live in the window. A failed request is not resent: the operation may have completed,
so inspect the document before retrying. The next request re-establishes the connection.
## Coordinates
+14 -3
View File
@@ -1,8 +1,19 @@
# Feature parity with InDesign
# InDesign feature checklist
The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a row, re-run).
> **Generated:** summary by `cargo xtask parity` from the rows below; rows last re-scored 2026-10-04, summary unchanged since (checked 2026-10-10) · **Target:** Adobe InDesign 2026
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
This is the **presence** checklist: does each InDesign feature exist in DesignCraft? It answers
the feature-breadth question only. Depth, fidelity and readiness for real work are assessed in
[target-app-parity.md](target-app-parity.md); individual shortfalls are in [gaps.md](gaps.md).
Formerly `docs/parity.md`.
Edit a row's status, then run `cargo xtask parity` to regenerate the summary; don't edit the
summary by hand. Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
Caveat (2026-10-10 review): rows were graded by the agents that built the features, at a coarse
grain. A measured match of InDesign 21.6's own menu dump against DesignCraft's labels finds 69%
of in-scope menu items (75% against the documented menu tree) (see [target-app-parity.md](target-app-parity.md)), so read 99% as an upper
bound.
<!-- SUMMARY -->
**Weighted breadth parity: 99%** over 276 features.
+115
View File
@@ -0,0 +1,115 @@
# DesignCraft roadmap (detail)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in
[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are
Opus 5.5 agent-hours (calibration in target-app-parity.md). Driving the app from agents:
[agents.md](agents.md).
## Alpha gate
InDesign's core workflows: what a layout professional does with it every day. DesignCraft is
alpha only when every one works end to end on macOS (its main platform) and the work saves and
reopens. **Gate result (2026-10-10): fails, so the stage is pre-alpha.** Rows marked "no" are the
alpha checklist; the partial rows don't block the workflow on macOS (there is a working path)
but are next in line.
| Core workflow | Works end to end? | Evidence | Hours to pass |
|---|---|---|---|
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 |
| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 |
| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 |
| Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 |
| Export a print-ready PDF (bleed, marks, CMYK and spot, PDF/X-4) | yes | krilla export with subset fonts, Separations, marks and bleed, PDF/X-4 with validator; 53 PDF tests | 0 |
Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part
of the alpha gate: IDML works both ways today and INDD is gap 2.
**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above
the ~40% alpha bar.
## Current focus
In order. Each closes a beta blocker from [gaps.md](gaps.md).
0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands,
out-port click with a loaded text cursor (click a frame or draw one), unthread by
double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h.
1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on
macOS, Windows and Linux. 30–50 h.
2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux;
Windows smoke start. 4–8 h. Needs the owner to make it a required check.
3. **Open INDD** (gap 2): decide with the owner how to bring in the clean-room converter
(#114 indd-utils, #98), then INDD → IDML → existing importer, warnings shown. 20–60 h.
4. **IDML real-file corpus** (gap 3): a pinned corpus, a survival metric, and fixes for #193,
#338, #339, #340. 60–100 h.
5. **Composition oracle** (gap 4): synthetic stories set in InDesign 2026 vs DesignCraft;
optical kerning; the overset rule. 80–140 h.
6. **Start on any GPU; find every installed font** (gaps 5, 6). 25–50 h.
## Milestones
States re-assessed 2026-10-10 against the code and the issue tracker. "Done" means the feature
set exists; depth gaps are in gaps.md.
| # | Milestone | State |
|---|---|---|
| M0 | Skeleton + vertical slice | ✅ |
| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) |
| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) |
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) |
| M4 | Color & effects | ✅ core; colour management depth |
| M5 | Graphics & links | ✅ core; EPS preview only |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | IDML ✅, PDF ✅ (X-4, A-2b, tagged); INDD ✗; PDF/X-1a/X-3 and presets ✗ |
| M7 | Long documents (sections, TOC, index, footnotes, books) | ✅ except Books (shallow) |
| M8 | Tables | ✅ core (table/cell styles, rotation); diagonal lines, anchored objects in cells (#163) |
| M9 | Performance (MT composition, tiles) | in progress: parallel compose 6.7×, damage regions; incremental composition next |
| M10 | Find/Change, spelling, Preflight | ✅ (spelling English only) |
| M11 | Layout power features (liquid/alternate layouts, data merge) | present, shallow; Flex Layout (new in InDesign 2026) ✗ |
| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images |
| M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ |
| M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started |
| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate |
| **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below |
## To beta
Beta needs ~75% ready for real work and no blocking gap in the main file format. From ~47% today
that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to five agents):
| Work | Gap | Hours |
|---|---|---|
| Basic editing on every platform | 1 | 30–50 |
| INDD import | 2 | 20–60 |
| IDML real-file corpus and fixes | 3 | 60–100 |
| Composition oracle and fixes | 4, 13 | 80–140 |
| Start-up robustness, fonts | 5, 6 | 25–50 |
| CI, Windows/Linux runtime tests | 7, 14 | 35–70 |
| IME | 8 | 10–20 |
| PDF/X-1a/X-3, presets, physical print validation | 9, 10 | 65–110 |
| Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 |
| Long documents (Books) | 15 | 30–50 |
| **Total** | | **420–750** |
## After beta: to full parity
Remaining gaps 16–25: the rest of the menu long tail, HTML/EPUB depth, colour management, placed
graphics depth (PostScript), performance on large documents, localization to `full` in the
twelve languages, InDesign scripting compatibility (owner decision), AI features (owner decision),
accessibility. **730–1,200 h** more; **1,150–1,950 h** in total from today.
## Superseded plan
The order the project followed until 2026-10-04 ("Next: books · New Window / split view · Ink
Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4
output intent · EPS place") is done except Books depth and EPS (preview only).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points |
| 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 |
| 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure |
+449
View File
@@ -0,0 +1,449 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask parity`).
## Headline
| Number | Value | Kind |
|---|---|---|
| **Feature breadth** (does each InDesign feature exist?) | **~80%** (range 69–99%) | measured lower and upper bounds, estimate between them (below) |
| **Ready for real work** (could an InDesign professional switch for real jobs?) | **~47%** (range 42–52%) | estimated, weighted sum (below); 1,150–1,950 h to ~95% |
| Mainstream practitioner (weekly core of a typical InDesign pro) | **~42%** | 500–850 h to ~95%; estimated, [method below](#mainstream-practitioner-42) |
| Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) |
| Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated |
| Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated |
| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) |
## How breadth was measured
Two measurements bracket it, and neither is the answer by itself.
- **Checklist: 99% (upper bound, measured, self-graded rows).** `cargo xtask parity` scores the 275
rows of [parity-checklist.md](parity-checklist.md), weighted P0 = 3, P1 = 2, P2 = 1: 269 done,
6 partial, 0 missing. The rows are a catalogue we wrote and were graded by the agents that built
the features; "done" means the feature exists, not that it has all of InDesign's options.
- **Menu dump: 69% (lower bound, measured 2026-10-10).** The main menu tree of the running
InDesign 2026 21.6, dumped through its scripting DOM during black-box observation
(`plan/indesign/screenshots/menus.txt`, local, never committed: 1,121 entries, 798 leaf items),
was matched by a script against every label in the DesignCraft source (engine command labels,
`UI_COMMANDS`, the `MENUS` table, dialog and panel strings; case, punctuation and "Show/Hide"
variants normalized). Excluded as out of scope or dynamic: the installed-font list (490 items),
Plug-Ins, Adobe cloud and AI services (Firefly, Generate, Rewrite, Auto Style, Stock, CC
Libraries, Publish Online, Share for Review), InCopy, Help links, window tiling, recent files
and workspace names. **331 of 482 in-scope items match (68.7%)**: File 19/26, Edit 28/31,
Layout 21/27, Type 49/92, Object 81/110, Table 26/45, View 40/57, Window 50/73, app menu 17/20.
It undercounts: some items exist under another label or only in a panel menu (Notes and Track
Changes have panels and `note.*` / `changes.*` commands but not InDesign's Type ▸ Notes /
Track Changes submenus).
- **Menu tree from documentation: 75% (cross-check).** The same match against the clean-room menu
tree compiled from public documentation (`plan/indesign/04-menu-tree.md`, 689 items): 440 of 590
in-scope items (74.6%).
- **Command surface (measured):** 446 engine commands (188 placed in menus) + 99 UI commands; the
menu bar has 9 menus, 49 submenus and 340 items. The original plan targeted ~1,000 commands.
The breadth figure is taken between them, at **~80%**, because the checklist overstates (rows were
written at a coarse grain and graded by their builders) and the label match understates (label
differences, panel-only commands).
## How "ready for real work" was estimated
Each dimension below was judged from the code, the tests, the issue tracker (124 issues, 79 open on
2026-10-10, most from people trying the 0.4/0.5 releases on real work) and the InDesign 2026
reference material. Weights reflect what an InDesign professional hits first: opening the files
they receive, setting type exactly, and basic editing that never fails.
| Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file |
| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English and Spanish only | [typography-parity.md](typography-parity.md) |
| File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) |
| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) |
| Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) |
| Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) |
| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) |
| Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) |
| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) |
| Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) |
| AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) |
| **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | |
**How the number is built:** the plain weighted sum of the table above, per the craftrules
standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2×45 + 2×15 = 47.2% →
**~47%** (range 42–52%, ±5 points of judgement in the dimension values). An earlier version
today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That
multiplicative step was dropped to match the standard (discounts belong only to the mainstream
and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows,
and the missing manual threading is what holds the stage at pre-alpha through the gate.
## Mainstream practitioner and essentials user
Two narrower questions, computed with the craftrules method so every app's numbers compare. The
stage still follows the full number (~47%) and the core-workflow gate. All three are estimates.
| Audience | Ready | Opus 5.5 agent-hours to ~95% | Parallelizes | Work that dominates |
|---|---|---|---|---|
| Full target (ready for real work) | **~47%** | **1,150–1,950** | ~70% (per crate) | depth across all 19 areas, InDesign scripting compatibility, localization to full in 12 languages, typography fidelity, INDD/IDML exchange |
| Mainstream practitioner | **~42%** | **500–850** | ~65% | INDD import and IDML real-file corpus (80–160), composition fidelity against InDesign (120–200), depth of the weekly areas (150–240), PDF/X-1a, presets and printing (65–110), editing and threading (45–75), stability and CI (25–45), fonts (10–20) |
| Essentials user | **~54%** | **135–250** | ~50% | start-up robustness on every GPU (25–45), editing and discoverability bugs: context menus, copy/paste, installer (30–50), threading (15–25), text editing and fonts (20–40), printing on Windows (15–30), depth of the core features (30–60) |
Hours are calibrated as in [Remaining effort and calibration](#remaining-effort-and-calibration)
(1–2 h per feature arc, 2–4 h per behaviour cluster matched against an oracle). Each audience's
work is a subset of the one above it: essentials ⊂ mainstream ⊂ full. Human needs: the owner's
decision on INDD (#114), InDesign oracle captures on the owner's Mac, native-speaker review (full
only).
### Mainstream practitioner: ~42%
The typical InDesign professional (magazine, book, brochure, packaging-adjacent layout artist)
uses these weekly: the alpha-gate workflows plus their everyday tools. Excluded: plug-ins and
scripting compatibility, generative/cloud AI, InCopy and team workflows, specialist hardware,
interface languages other than the user's own. Depth here is what the feature does when it
works; interaction, stability and exchange problems are the discounts, so they aren't counted
twice.
| Area | Weight | Depth | Evidence |
|---|---|---|---|
| Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table |
| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only |
| Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange |
| Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply |
| Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction |
| Colour, swatches, stroke | 8% | 60% | hex entry, CMS depth |
| Images: place, fit, links, wrap | 10% | 60% | EPS preview only, Edit Original missing |
| Tables | 5% | 55% | anchored objects in cells, Table Options submenus |
| Layers | 3% | 75% | |
| Effects and transparency | 3% | 50% | fidelity unmeasured |
| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a or presets; physical print validation pending |
| View, navigation, undo, save | 3% | 70% | |
| **Weighted depth** | 100% | **60.7%** | |
Discounts (multiplicative):
| Discount | Factor | Evidence |
|---|---|---|
| Interaction fidelity | ×0.88 | right-click menus fail for some macOS users (#160, #238) and on Linux (#354); Paste in Place lands on page 1 (#348); formatting at a caret discarded (#289); font search focus (#335, #86) |
| Stability on real machines | ×0.92 | start-up failures on Intel Mac Metal (#334) and Windows drivers (#167, #273, #220, #192, #166); no CI on pull requests; macOS on Apple silicon is solid |
| File exchange with InDesign users | ×0.85 | `.indd` can't be opened (#21); IDML import/export bugs (#193, #338, #339, #340, #211); opened text reflows: optical kerning (#181), overset rule (#180). Harsher than VectorCraft's ×0.92 because InDesign pros receive and send native files daily |
60.7% × 0.88 × 0.92 × 0.85 = 41.8% → **~42%**. It is lower than the full number (47%)
because the full number spreads weight over dimensions DesignCraft does relatively well
(performance, platforms, breadth of the long tail: 99% checklist presence), while the weekly core
carries the gaps at full weight: threading, editing bugs and INDD exchange.
### Essentials user: ~54%
Someone making a flyer, poster, newsletter or simple booklet: they touch only the core.
Excluded: advanced options, pro workflows (preflight, separations, books, long-document
features), exchange edge cases, and everything excluded above.
| Core feature | Weight | Depth | Evidence |
|---|---|---|---|
| Launch, welcome screen, new document from a preset | 10% | 80% | presets incl. A6 (#248) |
| Type text in a frame, select and edit it | 15% | 65% | caret formatting (#289), double-click selection in fields (#189) |
| Font, size, colour, alignment | 15% | 65% | installed fonts not always found (#161, #110, #107) |
| Place an image and fit it | 10% | 75% | place gun, fitting |
| Draw shapes and lines, fill and stroke | 10% | 80% | |
| Move, resize, rotate, align, duplicate | 10% | 75% | |
| Copy and paste | 5% | 60% | works on macOS; Paste in Place to the wrong page (#348) |
| Undo / redo | 5% | 85% | |
| Save and reopen | 5% | 85% | `.designcraft` round trip, recovery |
| Export PDF / PNG | 10% | 80% | |
| Print | 5% | 40% | PDF to CUPS/Windows via craft-print; physical output validation pending |
| **Weighted depth** | 100% | **72.0%** | |
| Discount | Factor | Evidence |
|---|---|---|
| Launch and stability | ×0.90 | the start-up failures above hit casual users first and they don't know `WGPU_BACKEND` |
| Discoverability and UI clarity | ×0.88 | a faithful, dense InDesign UI; right-click menus don't open for some users; the out port looks clickable but does nothing; Windows installer confusing (#64) |
| Opening files people send them | ×0.95 | casual users get Word, images and PDFs, which place well (docx/rtf/xlsx, PDF pages); INDD is rare for them |
72.0% × 0.90 × 0.88 × 0.95 = 54.2% → **~54%**.
### User evidence (GitHub issues and comments, 2026-10-10)
126 issues and 94 comments; no issues were filed by the maintainer (echelon).
- **Praise:** 7 people in 6 threads (#32 "so damn impressive… InDesign open source for Linux",
#77, #107, #113, #197, #328).
- **Switched-from-InDesign reports:** 0 completed switches. 2 former InDesign users trying real
work: #348, a retired designer who lost an Adobe licence, laying out 18 pages; #140. Both were
blocked by core-path bugs (paste to page, threading). #320 is an art director with 10 years of
daily InDesign asking for a feature.
- **Open issues (81):**
- 44 are core-path bugs (54%), clustering into a few root causes: copy/paste (8),
context menus (5), threading (4), launch (6), fonts (5).
- 9 are InDesign file-exchange bugs.
- 26 are niche requests or polish (HTML export, QuarkXPress import, MCP gateway, theme options).
- 2 are meta.
So the user evidence matches the numbers: interest and goodwill are high, and real work is
stopped by a short list of core-path bugs.
### Why the number went down
The 2026-10-04 estimate said "~83% including depth" and 170 h remaining. It was written before
anyone outside the project had used the app. New evidence since then:
- 79 open issues, many about core editing on Windows and Linux (copy/paste, context menus,
threading, fonts not found, startup crashes), filed against the 0.4 and 0.5 releases.
- The first measured menu coverage: 69% of the in-scope items of InDesign 21.6's own menu dump.
- The main file format: InDesign users receive `.indd` files, which DesignCraft can't open. The
previous estimate treated INDD as out of scope; a professional can't.
- No comparison of composition against InDesign exists, so typographic fidelity is unproven.
## Features by area
Breadth is the checklist's measured score; Ready is this review's estimate of depth and reliability.
Hours overlap with the dimension table (UI and format work sits in both views); the dimension
table is the one summed.
| Area | Breadth (checklist) | Ready | Hours | Main gaps |
|---|---|---|---|---|
| Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu |
| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) |
| Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth |
| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, live-corner handle (#186), reference point relocation (#337), Join/Reverse Path/Convert Point menu |
| Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth |
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids |
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer |
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth |
| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) |
| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent |
| Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI |
## Remaining effort and calibration
**Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10
`origin/main` had 470 commits (77 merged pull requests), 126,411 lines of Rust in 276 files and
1,122 `#[test]` functions. Commit timestamps show ~43 wall-clock hours of activity (gaps under
45 minutes), with one to five agents working in parallel, so roughly **100–150 Opus 5.5
agent-hours** built the current app: ~0.25 h per commit, **1–2 h per feature arc** (a command,
dialog, IDML and PDF support, tests). The community fixes of 2026-10-09/10 (text composition edge
cases #282–#298, 40–400 lines each) took about **1–2 h each**. Depth work against an oracle
(matching InDesign's behaviour exactly) is slower than adding features: **2–4 h per behaviour
cluster**, more when the oracle has to be built first.
| Work | Opus 5.5 h | Parallelizes? | Needs a human |
|---|---|---|---|
| Beta blockers (see [roadmap.md](roadmap.md)) | 420–750 | ~70% (per crate) | owner decision on INDD import (#114); InDesign oracle captures on the owner's Mac |
| Rest of full parity | 730–1,200 | ~70% | native-speaker review for 12 languages; owner decision on ExtendScript compatibility and AI models |
| **Total to full parity** | **1,150–1,950** | | |
With four to five agents in parallel and an integrator, the beta work is ~120–200 wall-clock
hours.
## Methodology and sources
- **Target:** Adobe InDesign 2026, version 21.6.0.57, installed at `/Applications/Adobe InDesign
2026/`. Inspected without launching and without reading anything inside the bundle beyond names
and listings (clean-room rule, AGENTS.md): `Info.plist` `CFBundleDocumentTypes` and
`UTExportedTypeDeclarations` (formats), `.lproj` folder names (25 UI localizations), plug-in
folder names (filters), `Presets/` folder names (swatch libraries, workspaces, shortcut sets).
- **Reference material:** `plan/indesign/01–11` (local, gitignored): overview, UI/UX, feature
catalogue, menu tree, tools, panels, dialogs, file formats, shortcuts, typography, and
observations of the running app.
- **DesignCraft measured from source on `origin/main` (92649ff, 2026-10-10)**: command registry,
`MENUS`, i18n catalogs, crate line and test counts, format modules. No local build (disk).
- **User evidence:** GitHub issues #1–#352 and open PRs (65 open, including the INDD converter
proposals #98 and #114).
## Appendix: what works today
The inventory below was the "Working today" list of ROADMAP.md (moved here 2026-10-10, unchanged).
It is evidence of breadth: each item exists. It is not a claim of InDesign-level depth.
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — 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.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- 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`.
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step.
- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize.
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text.
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<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; IDML Kashidas): 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, Italian, 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. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- 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, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), 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; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); 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.
Task notes that headed ROADMAP.md:
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- 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 remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points |
| 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated |
| 2026-10-10 | major | Created from ROADMAP.md's "How far from full parity" (2026-10-04) and "Working today"; full re-measure against InDesign 2026 21.6: menu coverage measured against InDesign 21.6's menu dump (69%) and the documented tree (75%), breadth set at ~80%, ready-for-real-work re-estimated 83% → 42% on user-reported evidence and the INDD gap; hours recalibrated from git history |
| 2026-10-04 | major | (in ROADMAP.md) breadth 99% weighted over 275 rows, ~83% including depth, ~170 h remaining |
+90
View File
@@ -0,0 +1,90 @@
# Typography and composition parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
from InDesign: different line breaks, different page count, overset where InDesign fits. This
document tracks both: does the attribute exist, and does it compose like InDesign? Script-specific
detail lives in [cjk-typography.md](cjk-typography.md), [cjk.md](cjk.md) and
[arabic-typography.md](arabic-typography.md). Overall numbers: [target-app-parity.md](target-app-parity.md).
**Dimension score: ~45% ready (estimated), 120–200 h to parity.** Presence is high (76% scored below, nothing absent); fidelity
against InDesign is **unmeasured**, which is the main gap.
## Fidelity: how we'd know
There is no oracle yet. What exists: unit tests of break points and invariants in
`crates/compose/src/tests.rs` (165 tests in the crate), IDML files built by DesignCraft and opened
in InDesign 2026 by hand, and user reports comparing the two (#179, #180, #181, #282–#295).
**Needed (gap 4 in [gaps.md](gaps.md)):** a set of synthetic stories (our own text and fonts:
Source Serif 4, Inter, Shippori Mincho from craft-fonts) covering each row below, set in InDesign
2026 by observation on the owner's Mac (results under `plan/`, never committed), exported as IDML;
DesignCraft opens the IDML and a test compares per paragraph: line count, break positions (by
character offset), last-line width, overset and frame fit. Report the share of paragraphs whose
breaks match exactly. Target for beta: ≥ 90% of Latin body paragraphs identical, 100% fit in the
same frames.
## Checklist
Status: **✓** present and believed close · **~** present, known differences or partial · **✗** missing.
Fidelity: **unmeasured** unless stated.
| Area | InDesign 2026 | DesignCraft | Status | Notes / evidence |
|---|---|---|---|---|
| Paragraph Composer | Adobe's multi-line composer | Knuth–Plass total fit, fitness classes, hyphen penalties | ~ | same family of algorithm, Adobe's weights unknown; breaks will differ on some paragraphs |
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
| OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | |
| Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | |
| Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md |
| First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | |
| Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits |
| Baseline grid | document and frame grids | ✓ | ✓ | |
| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | |
| Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) |
| Column rules | ✓ | ✓ (screen, PDF, IDML) | ~ | no stroke types or overprint (#274) |
| Keep options and breaks | keep with next, lines together, start in next column/frame/page/odd/even | ✓ | ✓ | break characters end paragraphs (#285); the line before a break is a last line (#283) |
| Drop caps, nested, line and GREP styles | ✓ | ✓ | ✓ | |
| Bullets and numbering | lists, levels, formats, restart, across stories | ✓ | ~ | numbering expressions from IDML (#298); Convert Bullets/Numbering to Text missing |
| Tabs | left/center/right/decimal/align-on, leaders, right-indent tab | ✓ | ~ | **Tabs panel missing** (#326); wrapping of lines with tabs fixed (#282) |
| Paragraph rules, borders, shading | ✓ | ✓ | ✓ | |
| Balance ragged lines | ✓ | ✓ | ✓ | |
| Optical margin alignment | ✓ | ✓ (hang table of our own) | ~ | hang amounts are ours, InDesign's unknown |
| Text wrap | bounding box, shape, jump object/column, contour options | ✓ | ✓ | |
| Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) |
| Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet |
| Type on a path | ✓ | ✓ | ✓ | |
| Vertical text (CJK) | ✓ | ✓ | ~ | see cjk.md |
| CJK composition | mojikumi, kinsoku, aki, tsume, ruby, kenten, warichu, frame grid | most; mojikumi tables kept but not applied; ruby group-only; no frame grid | ~ | cjk.md, cjk-typography.md |
| Arabic / Hebrew | kashida, digits, diacritic positioning, RTL stories and tables | ✓ | ~ | vendor justification presets unsupported; list bullets and multi-column RTL (#100) |
| Missing glyphs and fonts | pink highlight, substitution | ✓ | ✓ | |
| Installed fonts | every activated font | system font folders; user folders, font managers and Adobe Fonts not always found | ~ | #161, #110, #107, #327, #261 |
| Composition highlights | H&J, keeps, custom tracking/kerning, substituted fonts | ✓ | ✓ | |
Measured from the table above (2026-10-10): 34 rows, 18 ✓, 16 ~, 0 ✗, scoring ✓ = 1 and ~ = 0.5:
**76%**. Every row exists in some form; the ~ rows are where InDesign users will see a difference.
## Remaining effort
| Work | Opus 5.5 h |
|---|---|
| Oracle harness (synthetic stories, IDML exchange, comparison metric) | 20–30 |
| Optical kerning approximation (outline-based side-bearing model) + preflight warning | 15–25 |
| Overset rule (baseline fits) and frame-fit parity | 5–10 |
| Fixing composer differences the harness finds (weights, penalties, glyph-scaling order) | 40–70 |
| Hyphenation patterns for the top 15 InDesign languages (permissive sources) | 20–35 |
| Spelling dictionaries beyond English (licence review per language) | 10–20 (+ owner review) |
| Tabs panel, list conversion commands | 8–15 |
| **Total** | **120–200** |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created: typography checklist (34 rows), fidelity gap and oracle plan |
+77
View File
@@ -0,0 +1,77 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
(`plan/indesign/11-observed-ui.md`, local); behaviour from `plan/indesign/02-ui-ux.md`,
`05-tools.md`, `06-panels.md`, `07-dialogs.md` and `09-shortcuts.md`, and from user reports.
Overall numbers: [target-app-parity.md](target-app-parity.md).
**Dimension score: ~45% ready (estimated), 120–180 h.** The chrome looks like InDesign; the
basics of editing fail for too many users on Windows and Linux.
## Measured
| What | Value | How |
|---|---|---|
| InDesign 21.6 menu items matched (in scope) | 331 / 482 (69%) | script over the running app's menu dump; see target-app-parity.md |
| Menu bar | 9 menus, 49 submenus, 340 items | `MENUS` in `crates/ui-egui/src/menus.rs` |
| Commands | 446 engine (188 in menus) + 99 UI | `crates/engine/src/cmd/*`, `UI_COMMANDS` |
| Theme colours | measured hex values of Medium Dark, 4 brightness themes + High Contrast | `theme::Tokens` vs `11-observed-ui.md` §1 |
## Checklist
**✓** like InDesign · **~** present, differs or unreliable · **✗** missing.
| Area | InDesign 2026 | DesignCraft | Status | Evidence |
|---|---|---|---|---|
| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md |
| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
| Live corners (yellow handle) | ✓ | ✓ | ~ | not working for some users (#186) |
| Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | |
| Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) |
| Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | |
| Gridify while drawing, Gap tool, Live Distribute | ✓ | ✓ | ✓ | |
| Type tool: caret, selection, double/triple click, drag-and-drop text | ✓ | ✓ | ~ | formatting at a caret discarded (#289); text editing complaints (#23, #174) |
| Threading: click out port, load text cursor, click frame | ✓ | ✗ ports drawn only; no thread/unthread command; Primary Text Frame and autoflow on Place | ✗ | **alpha blocker** (#352, #194, #165, #140) |
| Copy / cut / paste objects and text, Paste Into / in Place | ✓ | ✓ on macOS | ~ | **failing on Windows/Linux** (#351, #348, #312, #185, #164, #201, #212) |
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
| IME text input on the canvas | ✓ | ✗ | ✗ | #322 |
| Font menu: search, preview, favourites, type-ahead | ✓ | ✓ | ~ | search field focus on macOS (#335, #86) |
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | |
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info | ✓ | all present | ~ | **Tabs panel missing** (#326); Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
| Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) |
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | |
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |
| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |
Counted 2026-10-10: 27 rows, 11 ✓, 14 ~, 2 ✗ → **67% presence**, scoring ✓ = 1, ~ = 0.5. Ready
for real work is lower, ~45%, because threading is missing and three of the ~ rows (copy/paste,
context menus, typing at a caret) are things a layout artist does every minute, and their failures stop work.
## Remaining effort
| Work | Opus 5.5 h |
|---|---|
| Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 |
| Canvas IME (port from VectorCraft) | 10–20 |
| Missing menu items (~150) and panels (Tabs, Navigator, Trap Presets) | 40–70 |
| Handle/tool feel fixes (#186, #188, #337), font search focus | 10–20 |
| Dialog depth pass against 07-dialogs.md | 30–40 |
| **Total** | **120–180** (overlaps gaps 1, 8, 16) |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) |
| 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues |
+15 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package DesignCraft for Linux (<arch> is x86_64 or aarch64):
# Build and package DesignCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/designcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/designcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
@@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do
esac
done
ARCH="$(uname -m)"
ARCH="${CROSS_ARCH:-$(uname -m)}"
case "$ARCH" in
riscv64) DEB_ARCH=riscv64 ;;
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
@@ -42,9 +43,13 @@ BASENAME="designcraft-$VERSION-linux-$ARCH"
echo "==> DesignCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
if [ -n "${CROSS_TARGET:-}" ]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
fi
fi
BIN="$CARGO_TARGET_DIR/release"
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
@@ -52,7 +57,7 @@ rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/designcraft" "$STAGE/usr/bin/designcraft"
install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli"
strip "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
@@ -145,6 +150,10 @@ if has appimage; then
fi
fi
"$STAGE/usr/bin/designcraft-cli" --version
if [ -z "${CROSS_TARGET:-}" ]; then
"$STAGE/usr/bin/designcraft-cli" --version
elif [ -n "${EMULATOR:-}" ]; then
"$EMULATOR" "$STAGE/usr/bin/designcraft-cli" --version
fi
echo "==> done"
ls -lh "$DIST"
+2 -2
View File
@@ -73,7 +73,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 6;
@@ -285,7 +285,7 @@ mod tests {
#[test]
fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
for dep in ["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
}
+1 -1
View File
@@ -24,7 +24,7 @@ commands:
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity recompute the InDesign feature-parity summary in docs/parity.md
parity recompute the InDesign feature-parity summary in docs/parity-checklist.md
ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
";
+3 -3
View File
@@ -1,4 +1,4 @@
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity-checklist.md from its row
//! table (each InDesign feature scored D / P / M, weighted by priority).
use std::path::Path;
@@ -69,10 +69,10 @@ pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
}
pub fn run(root: &Path) -> Result<(), String> {
let path = root.join("docs/parity.md");
let path = root.join("docs/parity-checklist.md");
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else {
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
return Err("docs/parity-checklist.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
};
let rows = rows(&text[b..]);
let s = summary(&rows);